chore(meta): resolve CHANGELOG conflict — merge main into visual
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Git Safety
|
||||
|
||||
## Staging rules
|
||||
|
||||
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||
|
||||
## Commit conventions
|
||||
|
||||
Use conventional commits: `<type>(<scope>): <summary>`
|
||||
|
||||
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local Services
|
||||
|
||||
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Project Structure (detailed)
|
||||
|
||||
```
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (archived here when complete)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders
|
||||
sprints/ # Sprint briefings per team
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
rules/ # Auto-loaded instruction modules
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index — use this to find specific D-records
|
||||
architecture.md # Architecture decisions
|
||||
perception.md # Perception and information system decisions
|
||||
content.md # Content and narrative decisions
|
||||
scope.md # Scope and feature decisions
|
||||
process.md # Process and workflow decisions
|
||||
questions.md # Open questions (Q-NNN)
|
||||
rejected.md # Rejected proposals (R-NNN)
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Gitea Access (tea CLI)
|
||||
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tooling/tea-comment <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
## Pull requests
|
||||
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Team Patterns
|
||||
|
||||
## Model selection
|
||||
|
||||
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||
sprint planning, large reviews), switch to extended context on-demand:
|
||||
|
||||
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||
|
||||
## Large content pushes
|
||||
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
## Team monitoring (stuck agent detection)
|
||||
|
||||
When leading a team (sprint, workshop, or any multi-agent session):
|
||||
|
||||
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||
> If you have been working on a single task for more than 15 minutes
|
||||
> without making progress, message the team lead with what is blocking
|
||||
> you. Do not keep retrying the same approach silently.
|
||||
|
||||
**Team lead proactive checks:**
|
||||
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||
@@ -44,6 +44,7 @@
|
||||
"Bash(make)",
|
||||
|
||||
"Bash(tea *)",
|
||||
"Bash(tooling/tea-comment *)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
|
||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
Semantic search across project documents. Endpoints are in
|
||||
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||
and workflows.
|
||||
|
||||
## Advanced Commands
|
||||
|
||||
|
||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
||||
|
||||
## Staging Rules
|
||||
|
||||
- Stage files by name — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
||||
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||
These apply to ALL git operations, not just this skill.
|
||||
|
||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
## Summary
|
||||
...
|
||||
EOF
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "<title>" \
|
||||
--description "$(cat /tmp/pr-body.md)" \
|
||||
--description "## Summary ..." \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
|
||||
@@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the branch
|
||||
### 0. Branch guard — MUST be on `main`
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise use the
|
||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
||||
which branch to review.
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||
"PR reviews must be run from the `main` worktree. Switch to `main` first."
|
||||
Do NOT proceed with the review from a team branch.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise list open
|
||||
PRs and ask the user which branch to review.
|
||||
|
||||
To list open PRs on Gitea:
|
||||
```bash
|
||||
@@ -174,19 +183,11 @@ After presenting results to the user, post the review as a PR comment.
|
||||
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup):
|
||||
|
||||
```bash
|
||||
tooling/tea-comment <PR_NUMBER> "review markdown here"
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
|
||||
@@ -8,12 +8,33 @@ description: >
|
||||
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||
sweep", "what's open", "sprint health".
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Sprint Status
|
||||
|
||||
Produce a consistent, scannable sprint health report. Closed work scrolls
|
||||
off the top; open work by team is visible at the bottom.
|
||||
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||
|
||||
When this skill is invoked, spawn a subagent using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "haiku",
|
||||
prompt: "Run /sprint-status. Read the skill at
|
||||
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||
(below the --- separator), then execute it.",
|
||||
description: "Sprint status report"
|
||||
)
|
||||
```
|
||||
|
||||
Present the subagent's output to the user verbatim. Do NOT run the
|
||||
workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is the subagent's reference for executing
|
||||
the workflow.
|
||||
|
||||
## Step 1 — Gather data
|
||||
|
||||
@@ -28,7 +49,7 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
|
||||
```
|
||||
|
||||
The `sweep` command returns JSON with:
|
||||
- `sprint` — id, name, status, goal
|
||||
- `sprint` — id, name, goal
|
||||
- `progress` — total, done, pct
|
||||
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||
- `by_team` — per-team counts
|
||||
@@ -57,10 +78,14 @@ Read `references/output-template.md` for the exact format spec.
|
||||
Render the report using data from steps 1-2. Key rules:
|
||||
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||
- Sort tickets within sections by team then ticket ID
|
||||
- Empty sections: show header with "(0)" and "(none)"
|
||||
- Bookkeeping Issues table: merge ticket-side issues from `sweep` with
|
||||
PR-side issues from step 2
|
||||
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||
- Open Work by Team: summary table at the bottom
|
||||
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||
`unassigned_in_progress` → "Unassigned in_progress",
|
||||
`assigned_but_done` → "Assigned but done",
|
||||
`done_team_open_pr` → "Done team with open PR",
|
||||
`orphan_pr` → "Orphan PR"
|
||||
|
||||
## Step 4 — Suggest actions
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Sprint Status Output Template
|
||||
|
||||
Use this template exactly when formatting `/sprint-status` output.
|
||||
|
||||
## Sprint {N}: {Theme} — Status Report
|
||||
|
||||
**Goal:** {goal}
|
||||
@@ -44,11 +42,9 @@ Use this template exactly when formatting `/sprint-status` output.
|
||||
|
||||
### Bookkeeping Issues
|
||||
|
||||
| Issue | Detail | Fix |
|
||||
|-------|--------|-----|
|
||||
| {type} | {detail} | `{command}` |
|
||||
|
||||
If no issues: "No bookkeeping issues found."
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| {type}: {detail} | `{command}` |
|
||||
|
||||
### Open Work by Team
|
||||
|
||||
@@ -56,15 +52,3 @@ If no issues: "No bookkeeping issues found."
|
||||
|------|---------|-------------|--------|---------|------|
|
||||
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||
|
||||
## Rendering rules
|
||||
|
||||
- Sections with 0 items: show header with "(0)" and a single line "(none)" — no empty table.
|
||||
- Sort tickets within each section by team, then by ticket ID.
|
||||
- Use the exact markdown table format above (pipe-separated, header row, separator row).
|
||||
- Bookkeeping issue types map to human-readable labels:
|
||||
- `unassigned_in_progress` → "Unassigned in_progress"
|
||||
- `stale_backlog` → "Stale backlog"
|
||||
- `assigned_but_done` → "Assigned but done"
|
||||
- `done_team_open_pr` → "Done team with open PR"
|
||||
- `orphan_pr` → "Orphan PR"
|
||||
|
||||
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Ticket Skill
|
||||
|
||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
||||
This skill covers the full command reference.
|
||||
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||
section. This skill covers the full command reference.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
+30
-8
@@ -7,8 +7,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- D-084: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||
@@ -18,6 +37,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||
|
||||
### Changed
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||
- Sprint 16 closed (8/8 done)
|
||||
@@ -30,6 +54,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||
- DialogueResponse cross-language fixture for GDScript testing
|
||||
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||
|
||||
### Fixed
|
||||
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||
@@ -44,14 +74,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- assert!(false) → panic!() in serialization tests (clippy)
|
||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||
|
||||
### Changed
|
||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||
|
||||
## [v0.1.15] — 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,51 +1,26 @@
|
||||
# The Settled Reach
|
||||
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Official Title:** The Settled Reach
|
||||
**Repository name:** settled-reach
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client/ # Godot 4 client (D-020)
|
||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
|
||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
questions.md # Q-001 through Q-011
|
||||
rejected.md # R-001 through R-010
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
```
|
||||
|
||||
Full annotated tree: `.claude/rules/project-structure.md`
|
||||
|
||||
## DevOps
|
||||
|
||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||
@@ -54,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
### Worktree boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||
|
||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
||||
|
||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
||||
|
||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- Do not navigate above the git root unless explicitly instructed.
|
||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -74,73 +46,18 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
|
||||
### Ticket and database access
|
||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
||||
```bash
|
||||
db/connectors/ticket list --sprint 2 --team server
|
||||
db/connectors/ticket show 78
|
||||
db/connectors/ticket sprint --active
|
||||
```
|
||||
### CLI tools
|
||||
|
||||
### Sprint CLI
|
||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
||||
```bash
|
||||
db/connectors/sprint status # Current sprint progress
|
||||
db/connectors/sprint status --team server # Team-scoped view
|
||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
||||
db/connectors/sprint start # Activate a planned sprint
|
||||
db/connectors/sprint stop # Complete an active sprint
|
||||
```
|
||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
||||
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||
|
||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
```
|
||||
|
||||
### Qdrant / document search
|
||||
```bash
|
||||
db/connectors/qdrant-search "asymmetric information design"
|
||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
||||
db/connectors/qdrant-health
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
|
||||
### Gitea access (tea CLI)
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
| Tool | Command | Full reference |
|
||||
|------|---------|----------------|
|
||||
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
@@ -149,35 +66,3 @@ Key rules:
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
|
||||
### Commit conventions
|
||||
Use conventional commits with project-specific scopes:
|
||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
### Pull requests
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
|
||||
### Large content pushes (team pattern)
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
### Local services
|
||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- Qdrant: `http://tower-of-joy:6333/`
|
||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=24 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -22,6 +22,7 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -118,6 +119,9 @@ zoom = Vector2(2, 2)
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
|
||||
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ extends Node2D
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@@ -104,7 +105,7 @@ func _process(delta: float) -> void:
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list._hide()
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
@@ -131,6 +132,10 @@ func _process(delta: float) -> void:
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# #263: Update time display (D-013, D-031)
|
||||
if time_display and time_display.has_method("update_from_state"):
|
||||
time_display.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
|
||||
## Tests for relationship color indicator in interaction_list.gd.
|
||||
##
|
||||
## Spec refs:
|
||||
## D-033 (entity color = relationship to player)
|
||||
## D-051 (diegetic insert display — insert-only overlay)
|
||||
## D-057 (interaction list, z-layer 6)
|
||||
##
|
||||
## Phase 1 scope (2026-02-24):
|
||||
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
|
||||
## server protocol change (no known_attributes in wire protocol v13).
|
||||
##
|
||||
## Implementation: client/ui/interaction_list.gd
|
||||
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
|
||||
## `func _cache_entity_relationship() -> void`
|
||||
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
|
||||
class_name TestETalkOverlaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.nearby_interactions = []
|
||||
GameState.visible_entities = []
|
||||
GameState.player_stance = ""
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
GameState.visible_entities = []
|
||||
GameState.player_stance = ""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_relationship_unknown_maps_to_teal() -> void:
|
||||
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_friendly_maps_to_green() -> void:
|
||||
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
|
||||
func test_relationship_poi_maps_to_amber() -> void:
|
||||
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
func test_relationship_hostile_maps_to_red() -> void:
|
||||
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
func test_relationship_known_falls_back_to_unknown_color() -> void:
|
||||
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
|
||||
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
|
||||
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_has_relationship_unknown_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_known_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_friendly_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_poi_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_hostile_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# _relationship_color initial state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_relationship_color_default_is_implant_dim() -> void:
|
||||
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
|
||||
var list = _make_list()
|
||||
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_cache_relationship_unknown_sets_unknown_color() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_friendly_sets_green() -> void:
|
||||
_setup_npc_interaction(2, "Friendly")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_poi_sets_amber() -> void:
|
||||
_setup_npc_interaction(2, "PersonOfInterest")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_hostile_sets_red() -> void:
|
||||
_setup_npc_interaction(2, "Hostile")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
|
||||
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
|
||||
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
|
||||
GameState.visible_entities = []
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_updates_when_relationship_changes() -> void:
|
||||
# First call: Unknown
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
# Relationship shifts → re-cache picks up new value
|
||||
_setup_npc_interaction(2, "Hostile")
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_targets_correct_entity_by_id() -> void:
|
||||
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
|
||||
# Entity 3 (Hostile) must not pollute the color
|
||||
GameState.visible_entities = [
|
||||
{
|
||||
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
|
||||
},
|
||||
{
|
||||
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
|
||||
},
|
||||
]
|
||||
GameState.nearby_interactions = [
|
||||
{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
},
|
||||
{
|
||||
"entity_id": 3, "entity_type": "Npc", "distance": 2,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
},
|
||||
]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
# interaction[0] = entity 2 (Friendly) → green bar
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
list.queue_free()
|
||||
|
||||
func test_z_layer_is_insert_canvas() -> void:
|
||||
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
|
||||
var list = _make_list()
|
||||
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Regression: interaction_list public API unaffected by #537 changes
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_list_hides_when_no_interactions() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_shows_on_npc_interaction() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_true()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_get_selected_verb_returns_first_verb() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
var list = _make_list()
|
||||
assert_that(list.get_selected_verb()).is_equal("Talk")
|
||||
list.queue_free()
|
||||
|
||||
func test_list_get_interaction_target_returns_entity_id() -> void:
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 5, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.get_interaction_target()).is_equal(5)
|
||||
list.queue_free()
|
||||
|
||||
func test_list_suppressed_when_insert_inactive() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.set_insert_active(false)
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_hides_on_empty_verb_list() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func skip_test_npc_name_displayed_when_known() -> void:
|
||||
pass
|
||||
|
||||
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
|
||||
pass
|
||||
|
||||
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func _make_list() -> Control:
|
||||
var scene = load("res://ui/interaction_list.tscn")
|
||||
var list = scene.instantiate()
|
||||
add_child(list) # _ready() fires here — @onready var _vbox resolves
|
||||
return list
|
||||
|
||||
|
||||
## Set up GameState with a single NPC entity + matching interaction for tests.
|
||||
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
@@ -0,0 +1,318 @@
|
||||
## Sprint 17 — Time display on insert HUD (#263)
|
||||
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
||||
##
|
||||
## Spec refs:
|
||||
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||
## D-051 (diegetic insert display)
|
||||
##
|
||||
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||
##
|
||||
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||
## reading Label.text via public node paths instead.
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var _clock: Control = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.game_time = {}
|
||||
var ClockScript = load("res://ui/time_display.gd")
|
||||
_clock = Control.new()
|
||||
_clock.set_script(ClockScript)
|
||||
add_child(_clock)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _clock and is_instance_valid(_clock):
|
||||
_clock.queue_free()
|
||||
_clock = null
|
||||
GameState.game_time = {}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Constants.format_game_time() — pure logic, D-031
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_format_midnight() -> void:
|
||||
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
||||
|
||||
func test_format_morning_start() -> void:
|
||||
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
||||
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
||||
|
||||
func test_format_noon() -> void:
|
||||
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
||||
|
||||
func test_format_evening_start() -> void:
|
||||
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
||||
|
||||
func test_format_end_of_day() -> void:
|
||||
# Last valid minute — must not wrap or overflow
|
||||
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
||||
|
||||
func test_format_pads_single_digit_hour() -> void:
|
||||
# 30 min = 00:30
|
||||
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
||||
|
||||
func test_format_pads_single_digit_minute() -> void:
|
||||
# 121 min = 02:01
|
||||
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
||||
|
||||
func test_format_half_past_hour() -> void:
|
||||
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
||||
|
||||
func test_format_arbitrary_midday() -> void:
|
||||
# 835 min = 13:55
|
||||
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock initial state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
||||
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
||||
assert_that(_clock._phase_str).is_equal("")
|
||||
|
||||
func test_insert_clock_initial_day_str_is_empty() -> void:
|
||||
assert_that(_clock._day_str).is_equal("")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.update_from_state() — reads GameState.game_time
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_formats_time_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("12:00")
|
||||
|
||||
func test_update_from_state_sets_phase_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_str).is_equal("Evening")
|
||||
|
||||
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
||||
# Day 0 from server → "D1" display (1-indexed)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D1")
|
||||
|
||||
func test_update_from_state_day_2() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D2")
|
||||
|
||||
func test_update_from_state_midnight() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("00:00")
|
||||
|
||||
func test_update_from_state_end_of_day() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("23:59")
|
||||
|
||||
func test_update_from_state_skips_empty_game_time() -> void:
|
||||
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
||||
GameState.game_time = {}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_update_from_state_deduplicates_same_tick() -> void:
|
||||
# Calling twice with identical data must produce same result (signature cache)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
# Call again — result unchanged, no crash
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
|
||||
func test_update_from_state_updates_on_new_tick() -> void:
|
||||
# time_of_day changes → signature changes → _time_str updates
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("01:00")
|
||||
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("02:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_phase_colors_has_morning() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
||||
|
||||
func test_phase_colors_has_afternoon() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
||||
|
||||
func test_phase_colors_has_evening() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
||||
|
||||
func test_phase_colors_has_night() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
||||
|
||||
func test_phase_color_applied_after_update() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
||||
|
||||
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
||||
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_game_time_populated_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
||||
|
||||
func test_game_time_all_four_phases_store_correctly() -> void:
|
||||
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
||||
|
||||
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
||||
|
||||
func test_game_time_zero_time_of_day_stored() -> void:
|
||||
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SimBridge test mode: game_time fields are valid
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_sim_bridge_snapshot_has_game_time() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("game_time")).is_true()
|
||||
assert_that(snap.game_time is Dictionary).is_true()
|
||||
|
||||
func test_sim_bridge_game_time_has_required_fields() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var gt: Dictionary = snap.game_time
|
||||
assert_that(gt.has("day")).is_true()
|
||||
assert_that(gt.has("time_of_day")).is_true()
|
||||
assert_that(gt.has("day_phase")).is_true()
|
||||
assert_that(gt.has("tick_rate")).is_true()
|
||||
|
||||
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
||||
|
||||
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
||||
# D-031: 1440 game-minutes per day, valid range 0..1439
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
||||
|
||||
func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var phase: String = snap.game_time.get("day_phase", "")
|
||||
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||
|
||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||
assert_that(clock).is_not_null()
|
||||
assert_that(clock._time_str).is_equal("12:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Regression: debug_overlay still reads game_time correctly (#511)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
||||
|
||||
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|
||||
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||
var _selected_index: int = 0
|
||||
var _active_tween: Tween = null
|
||||
var _verb_labels: Array[Label] = []
|
||||
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
@@ -39,10 +41,14 @@ func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
queue_redraw()
|
||||
if position != _last_screen_pos:
|
||||
_last_screen_pos = position
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -52,6 +58,9 @@ func _draw() -> void:
|
||||
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||
draw_rect(bg_rect, INSERT_BG)
|
||||
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
|
||||
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
|
||||
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
@@ -88,6 +97,7 @@ func update_from_state() -> void:
|
||||
_verb_items = sorted
|
||||
_selected_index = 0
|
||||
_cache_entity_position()
|
||||
_cache_entity_relationship()
|
||||
_rebuild_labels()
|
||||
_show()
|
||||
|
||||
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
|
||||
return
|
||||
|
||||
|
||||
## #537: Cache relationship color for the target entity (D-033 palette).
|
||||
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
|
||||
func _cache_entity_relationship() -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.get("entity_id") == _current_target_id:
|
||||
var rel: String = str(entity.get("relationship", "Unknown"))
|
||||
_relationship_color = Constants.color_for_relationship(rel)
|
||||
return
|
||||
_relationship_color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
|
||||
## Convert entity world position to screen coords and reposition this Control.
|
||||
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||
func _update_screen_position() -> void:
|
||||
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
|
||||
return _verb_items.size()
|
||||
|
||||
|
||||
func hide_list() -> void:
|
||||
_hide()
|
||||
|
||||
|
||||
func is_showing() -> bool:
|
||||
return _showing
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
extends Control
|
||||
|
||||
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
|
||||
## Shows station local time (HH:MM), day phase, and day number.
|
||||
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
|
||||
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
|
||||
##
|
||||
## Placeholder layout — position and style will be refined when #314 wireframe lands.
|
||||
|
||||
const FONT_SIZE_TIME: int = 15
|
||||
const FONT_SIZE_META: int = 10
|
||||
const PADDING := Vector2(10, 7)
|
||||
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
|
||||
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
|
||||
|
||||
# Day phase colors — station lighting cycle (D-031)
|
||||
const PHASE_COLORS := {
|
||||
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||
}
|
||||
|
||||
var _time_str: String = "--:--"
|
||||
var _phase_str: String = ""
|
||||
var _day_str: String = ""
|
||||
var _day_text: String = ""
|
||||
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
var _last_signature: String = ""
|
||||
var _has_data: bool = false
|
||||
|
||||
# Cached geometry — recomputed in update_from_state(), used in _draw()
|
||||
var _time_size: Vector2 = Vector2.ZERO
|
||||
var _phase_size: Vector2 = Vector2.ZERO
|
||||
var _day_size: Vector2 = Vector2.ZERO
|
||||
var _meta_h: float = 0.0
|
||||
var _box_w: float = 0.0
|
||||
var _box_h: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
var gt: Dictionary = GameState.game_time
|
||||
if gt.is_empty():
|
||||
return
|
||||
var tod: int = int(gt.get("time_of_day", 0))
|
||||
var day: int = int(gt.get("day", 0))
|
||||
var phase: String = str(gt.get("day_phase", ""))
|
||||
var sig: String = "%d:%d:%s" % [tod, day, phase]
|
||||
if sig == _last_signature:
|
||||
return
|
||||
_last_signature = sig
|
||||
_time_str = Constants.format_game_time(tod)
|
||||
_phase_str = phase
|
||||
_day_str = "D%d" % (day + 1)
|
||||
_day_text = " " + _day_str
|
||||
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
|
||||
_has_data = true
|
||||
_cache_geometry()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _cache_geometry() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
|
||||
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
|
||||
var meta_w := _phase_size.x + _day_size.x
|
||||
var content_w := max(_time_size.x, meta_w)
|
||||
_box_w = content_w + PADDING.x * 2
|
||||
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _has_data:
|
||||
return
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
|
||||
# HH:MM (primary, full brightness)
|
||||
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
|
||||
|
||||
# Phase + day number (secondary, dimmed + phase-tinted)
|
||||
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
|
||||
draw_string(font, Vector2(PADDING.x, meta_y),
|
||||
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
|
||||
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
|
||||
@@ -0,0 +1,22 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
|
||||
|
||||
; #263: Time display — top-left placeholder per D-013/D-051.
|
||||
; Position and size will be refined when #314 wireframe lands.
|
||||
; NOTE: draw-based content manages its own layout; Control rect is a
|
||||
; minimum bounding box, not a clip rect. Increase if content grows.
|
||||
[node name="TimeDisplay" type="Control"]
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 0.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = 16.0
|
||||
offset_top = 16.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 70.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_tdisplay")
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
character: detective
|
||||
location: contradiction
|
||||
lines:
|
||||
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
|
||||
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||
#
|
||||
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
|
||||
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
|
||||
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
|
||||
#
|
||||
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
|
||||
# does not fire for this arc.
|
||||
#
|
||||
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
|
||||
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
|
||||
#
|
||||
# Phase 2 lines: trust established but not deep — the blindsiding.
|
||||
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
|
||||
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
|
||||
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
|
||||
#
|
||||
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||
# fires once per KG state change), not by a cooldown value.
|
||||
#
|
||||
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_001
|
||||
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||
notes: >
|
||||
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
|
||||
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
|
||||
natural in a surprise moment even for the analytical detective — the observation breaks
|
||||
through the professional register. No interpretation, no accusation.
|
||||
|
||||
- id: pc-detective_m_d_con_002
|
||||
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||
notes: >
|
||||
Alternate primary — fully analytical register: surname-initial for both (institutional
|
||||
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
|
||||
detective's version of stating contradiction — clinical, process-language. Fires as
|
||||
alternate to con_001 for variety on replay.
|
||||
|
||||
- id: pc-detective_m_d_con_003
|
||||
text: "She placed Kael at the dock. He's not at the dock."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2]
|
||||
notes: >
|
||||
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
|
||||
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
|
||||
of contradiction. The brevity is the weight: six words and the investigation changes shape.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
|
||||
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_004
|
||||
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [anxious, focused]
|
||||
priority: 7
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
|
||||
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
|
||||
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
|
||||
the key line — it holds accusation off even against evidence. The detective files it open.
|
||||
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||
delay_after_trigger_ticks for proper sequencing.
|
||||
|
||||
- id: pc-detective_m_d_con_005
|
||||
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
|
||||
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
|
||||
information — the detective separates these without assuming either. 'That's a different
|
||||
question' closes the processing loop, marking it unresolved, not dismissed.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
|
||||
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_006
|
||||
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
|
||||
notes: >
|
||||
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
|
||||
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
|
||||
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
|
||||
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
|
||||
for the friend relationship — vs. the clinical observation about Lintar.
|
||||
|
||||
- id: pc-detective_m_d_con_007
|
||||
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
|
||||
notes: >
|
||||
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
|
||||
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
|
||||
without concluding. The numbered format ('Two facts... One new observation') is the
|
||||
detective's internal case-building style. Fully non-accusatory: the connection is not
|
||||
stated, only the intent to look for it.
|
||||
|
||||
- id: pc-detective_m_d_con_008
|
||||
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
|
||||
notes: >
|
||||
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
|
||||
the detective naming Sera's avoidance behavior — behavioral description, not motive
|
||||
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
|
||||
remains unstated. Detective is following the thread, not announcing what's on it.
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
character: detective
|
||||
location: tutorial
|
||||
lines:
|
||||
# Diegetic Tutorial Monologue — Ticket #330 — Detective
|
||||
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||
#
|
||||
# These lines teach mechanics through character voice — not UI instructions.
|
||||
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||
#
|
||||
# Coverage:
|
||||
# Movement / exploration — first_move, first_new_section
|
||||
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||
# Sound model — first_sound_heard, first_off_screen_sound
|
||||
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||
#
|
||||
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
|
||||
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
|
||||
# ID discriminator: _tut_ per sprint briefing.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||
# fire once per game), not by a cooldown value.
|
||||
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||
#
|
||||
# Schema corrections (Gestalt review, Sprint 17):
|
||||
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||
# string value would fail engine parse in monologue.rs.
|
||||
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||
# enum. Replaced with arrival, routine per actual trigger context.
|
||||
#
|
||||
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MOVEMENT / EXPLORATION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_001
|
||||
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_move
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, movement, navigation, analytical]
|
||||
notes: >
|
||||
Detective version of the 'check the signage' tutorial. Colon usage for the signage
|
||||
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
|
||||
framing; he reads the environment through his professional lens first. 'Worth learning
|
||||
before the district learns me' — dry awareness of his asymmetric visibility in this
|
||||
community. Same teaching as the smuggler version, different cognitive frame.
|
||||
|
||||
- id: pc-detective_m_d_tut_002
|
||||
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, investigation]
|
||||
trigger: first_new_section
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, movement, navigation, analytical]
|
||||
notes: >
|
||||
Fires on entering a new section for the first time. 'Commission briefing had the
|
||||
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
|
||||
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
|
||||
principle: documentation is a starting point, not a substitute for observation.
|
||||
Teaches: the district has distinct sections to explore.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FOG OF PERCEPTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_003
|
||||
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_encounter
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, fog, perception, analytical]
|
||||
notes: >
|
||||
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
|
||||
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
|
||||
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
|
||||
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
|
||||
the detective categorizes it as an information state, not a barrier. Compare to
|
||||
smuggler's 'Might be worth checking' — same fog, different response.
|
||||
|
||||
- id: pc-detective_m_d_tut_004
|
||||
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_edge
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, fog, perception, analytical]
|
||||
notes: >
|
||||
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
|
||||
procedure' — detective frames the limitation as methodology. The second clause
|
||||
('not a limitation, a working condition') is self-instruction: he's telling himself
|
||||
not to treat fog as frustrating but as standard operating environment. Teaches:
|
||||
perception range is persistent throughout gameplay, and the correct response is
|
||||
movement, not waiting.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SOUND MODEL
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_005
|
||||
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_sound_heard
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, sound, perception, analytical]
|
||||
notes: >
|
||||
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
|
||||
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
|
||||
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
|
||||
and what he can't (count, content). No contractions. Teaches: sound channel is active
|
||||
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
|
||||
reality, systematized differently.
|
||||
|
||||
- id: pc-detective_m_d_tut_006
|
||||
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_off_screen_sound
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, sound, perception, analytical]
|
||||
notes: >
|
||||
Second sound tutorial — fires on an off-screen audio source. The detective parses
|
||||
the sound through multiple analytical dimensions: surface type, count, direction,
|
||||
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
|
||||
signature' is technical terminology applied to diegetic audio interpretation.
|
||||
Teaches: sound carries more than presence — floor type, distance, count, direction
|
||||
are all extractable. The detective extracts all of them automatically.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# NPC INTERACTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_007
|
||||
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_proximity
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, npc, interaction, analytical]
|
||||
notes: >
|
||||
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
|
||||
his categorization (Commission-trained: everyone is a witness category until proven
|
||||
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
|
||||
is the investigative principle: don't prime witnesses, let them self-select information.
|
||||
No contractions throughout. Teaches: NPC interaction is player-initiated and
|
||||
approach-sensitive. Different method than the smuggler ('give them reason to') —
|
||||
same outcome, different social model.
|
||||
|
||||
- id: pc-detective_m_d_tut_008
|
||||
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, investigation]
|
||||
trigger: first_npc_face_read
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, npc, observation, analytical, authority]
|
||||
notes: >
|
||||
Fires when the player observes an NPC at close range. The detective's asymmetry:
|
||||
his authority access tier makes him visible in ways the smuggler is not. NPCs react
|
||||
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
|
||||
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
|
||||
the detective's Commission credentials affect NPC behavior, and that behavioral
|
||||
response is data.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# INSERT / HUD
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_009
|
||||
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
trigger: first_insert_open
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, insert, hud, analytical]
|
||||
notes: >
|
||||
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
|
||||
linked' — institutional framing, establishes why the detective's HUD is denser than
|
||||
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
|
||||
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
|
||||
show the time and nearby contacts' — same overlay, presented as procedural requirement
|
||||
vs. operational check.
|
||||
|
||||
- id: pc-detective_m_d_tut_010
|
||||
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
trigger: first_insert_contact_flag
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, insert, hud, lattice, investigation]
|
||||
notes: >
|
||||
Fires when the insert first populates with flagged entities. Count given precisely
|
||||
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
|
||||
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
|
||||
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
|
||||
insert is investigation-keyed, matching observed faces against case file entries.
|
||||
This is why his overlay is analytically denser than the smuggler's.
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
character: smuggler
|
||||
location: contradiction
|
||||
lines:
|
||||
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
|
||||
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||
#
|
||||
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
|
||||
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
|
||||
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
|
||||
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
|
||||
#
|
||||
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
|
||||
# does not fire for this arc.
|
||||
#
|
||||
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
|
||||
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
|
||||
# gut reaction, not analytical procedure.
|
||||
#
|
||||
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
|
||||
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
|
||||
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
|
||||
# shortened interactions, early exits from break room.
|
||||
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
|
||||
#
|
||||
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||
# fires once per KG state change), not by a cooldown value.
|
||||
#
|
||||
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_001
|
||||
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||
notes: >
|
||||
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
|
||||
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
|
||||
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
|
||||
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
|
||||
|
||||
- id: pc-smuggler_m_s_con_002
|
||||
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||
notes: >
|
||||
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
|
||||
recognition first, then the wrong details fall into place. Each fragment adds one layer
|
||||
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
|
||||
expectation, which is now violated. No contraction in the closing declarative (emphasis).
|
||||
|
||||
- id: pc-smuggler_m_s_con_003
|
||||
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2]
|
||||
notes: >
|
||||
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
|
||||
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
|
||||
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
|
||||
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
|
||||
Epistemic containment in plain language.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
|
||||
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_004
|
||||
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 7
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
|
||||
gives Kael benefit of the doubt, then immediately walks it back with operational
|
||||
logic. 'There's always a reason' is the street-smart equivalent of epistemic
|
||||
containment — she's seen strange things before. The closing line is where the alarm
|
||||
lives: this isn't strange, it's a protocol violation.
|
||||
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||
delay_after_trigger_ticks for proper sequencing.
|
||||
|
||||
- id: pc-smuggler_m_s_con_005
|
||||
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
|
||||
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
|
||||
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
|
||||
understatement. Operational logic applied to an emotional moment: the smuggler
|
||||
keeps the processing clean even when the stakes are personal.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
|
||||
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_006
|
||||
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
|
||||
notes: >
|
||||
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
|
||||
catalog builds the picture: three days of signals, then this. Closing line names the
|
||||
conclusion without naming its implication: 'working an angle' is street-smart language
|
||||
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
|
||||
she doesn't know that yet.
|
||||
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
|
||||
or simplify to a different check.
|
||||
|
||||
- id: pc-smuggler_m_s_con_007
|
||||
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, operational]
|
||||
notes: >
|
||||
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
|
||||
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
|
||||
means either Devra is running something Kael's involved in (plausible, alarming) or
|
||||
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
|
||||
good' — the smuggler's understated close for a significant threat read.
|
||||
|
||||
- id: pc-smuggler_m_s_con_008
|
||||
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [anxious]
|
||||
priority: 8
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, intuition]
|
||||
notes: >
|
||||
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
|
||||
describes the behavioral changes without cataloging them. 'Now I know why' — the click
|
||||
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
|
||||
out' is street-smart language for exit attempt: the smuggler doesn't know the details
|
||||
but the read is right. This line is the closest the smuggler comes to naming what's
|
||||
happening — and she still doesn't say it explicitly.
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
character: smuggler
|
||||
location: tutorial
|
||||
lines:
|
||||
# Diegetic Tutorial Monologue — Ticket #330 — Smuggler
|
||||
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||
#
|
||||
# These lines teach mechanics through character voice — not UI instructions.
|
||||
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||
#
|
||||
# Coverage:
|
||||
# Movement / exploration — first_move, first_new_section
|
||||
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||
# Sound model — first_sound_heard, first_off_screen_sound
|
||||
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||
#
|
||||
# Voice: observational, street-smart, practical. Fragments. Standalone "Good." as tic.
|
||||
# No analytical distance — the smuggler reads, reacts, files, moves on.
|
||||
# ID discriminator: _tut_ per sprint briefing.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||
# fire once per game), not by a cooldown value.
|
||||
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||
#
|
||||
# Schema corrections (Gestalt review, Sprint 17):
|
||||
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||
# string value would fail engine parse in monologue.rs.
|
||||
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||
# enum. Replaced with arrival, routine, observation per actual trigger context.
|
||||
# mood: [focused] retained where set; valid per D-035 Sprint 14 amendment.
|
||||
#
|
||||
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MOVEMENT / EXPLORATION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_001
|
||||
text: "These corridors all look the same. Mental note: check the signage."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_move
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, movement, navigation]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First move teaches navigation: the district
|
||||
looks homogeneous, signage is the tool. 'Mental note' is the smuggler's internal
|
||||
flag for things to remember — operational housekeeping, not analysis. Establishes
|
||||
early: this environment requires active spatial tracking.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_002
|
||||
text: "Two lefts past the junction, right at the supply hatch. That's dock four. Route's in the muscle now."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_new_section
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, movement, navigation, operational]
|
||||
notes: >
|
||||
Fires on entering a new section for the first time. 'Route's in the muscle' — the
|
||||
smuggler doesn't memorize spatially, she moves until it's automatic. Practical,
|
||||
self-sufficient. Teaches: the district has multiple sections, each requires its own
|
||||
orientation. 'Dock four' is a landmark. Forward movement is rewarded with familiarity.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FOG OF PERCEPTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_003
|
||||
text: "Can't see past that corner. Might be worth checking."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_encounter
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, fog, perception]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First fog boundary. Restated as habit:
|
||||
the smuggler doesn't treat the fog as a limitation, she treats every unknown corner
|
||||
as a question worth answering. 'Might be worth' — not paranoia, just operational
|
||||
instinct. Teaches: the perception boundary is navigable, not fixed.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_004
|
||||
text: "Haze cuts off past the junction. Anything in it — can't say."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_edge
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, fog, perception]
|
||||
notes: >
|
||||
Second fog encounter line — fires when the player reaches the boundary of a fog zone
|
||||
(the edge of visible range). 'Can't say' — plain statement of unknowing, not anxiety.
|
||||
The smuggler accepts incomplete information as a working condition, not a failure state.
|
||||
Teaches: perception range is an ongoing factor, not a one-time obstacle.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SOUND MODEL
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_005
|
||||
text: "Voices down the hall. Can't make out the words from here."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_sound_heard
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, sound, perception]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First sound heard that's not immediately
|
||||
visible. Teaching: sound carries information, but range and occlusion limit it.
|
||||
'Can't make out the words' establishes both the value of the sound channel (voices =
|
||||
people = relevant) and its limitation (unclear at range). Moving toward source is
|
||||
the implied next step.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_006
|
||||
text: "Footsteps. Two sets. Moving away — good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_off_screen_sound
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, sound, perception, operational]
|
||||
notes: >
|
||||
Second sound line — fires when audio from an off-screen source is detected. The
|
||||
smuggler parses footsteps as count and direction instinctively. 'Moving away — good.'
|
||||
— the standalone 'Good.' tic applied to tactical assessment. Teaches: sound carries
|
||||
directional and movement data, not just presence. Two sets = two people, which is
|
||||
specific enough to be operationally useful.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# NPC INTERACTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_007
|
||||
text: "Could ask around. People talk if you give them reason to."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_proximity
|
||||
mood: [content]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, npc, interaction, social]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First time in range of an NPC. The smuggler
|
||||
doesn't approach directly — she flags the option. 'Give them reason to' is street-smart
|
||||
social knowledge: people don't volunteer freely, they need a hook. Teaches: NPC
|
||||
interaction is player-initiated, and approach matters. No mechanics text — just the
|
||||
character's own method stated as personal principle.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_008
|
||||
text: "Know the face. Don't need the name."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_face_read
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, npc, observation, social]
|
||||
notes: >
|
||||
Fires when the player observes an NPC at close range (face visible). The smuggler
|
||||
reads people by face, not by name — the name comes later if relevant. Teaches: NPCs
|
||||
are identifiable by observation, and the insert overlay will fill in names when
|
||||
available. The smuggler's social intelligence is pre-lattice, instinctive.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# INSERT / HUD
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_009
|
||||
text: "Check the overlay. Should show the time and nearby contacts."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
trigger: first_insert_open
|
||||
mood: [content]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, insert, hud, operational]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First insert/HUD open. The smuggler's
|
||||
overlay shows time and nearby contacts — both operationally critical. 'Should show'
|
||||
— practical, not certain. Teaches: the insert is the primary tool for time-tracking
|
||||
and NPC awareness. Diegetic: she checks it the same way she checks a shift board.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_010
|
||||
text: "Overlay marks Kael two sections over. Good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, social]
|
||||
trigger: first_insert_contact_flag
|
||||
mood: [content, warm]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, insert, hud, kael, friend-arc]
|
||||
notes: >
|
||||
Fires when the insert first flags a known contact (here: Kael, the FRIEND NPC).
|
||||
'Two sections over' — spatial data from the overlay. 'Good.' standalone tic —
|
||||
warm, settling. Teaches: the insert shows known NPC positions in real-time.
|
||||
Also seeds the Kael relationship: the smuggler checks his location as a habit,
|
||||
not a task. His position is always relevant to her.
|
||||
@@ -529,7 +529,6 @@ def cmd_sweep(args):
|
||||
"title": t["title"],
|
||||
"team": t.get("team") or "unassigned",
|
||||
"assigned_to": t.get("assigned_to"),
|
||||
"priority": t["priority"],
|
||||
}
|
||||
if t["status"] == "done":
|
||||
by_status["done"].append(entry)
|
||||
@@ -559,22 +558,19 @@ def cmd_sweep(args):
|
||||
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "unassigned_in_progress",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} is {t['status']} but has no agent assigned",
|
||||
"detail": f"#{t['id']} unassigned {t['status']}",
|
||||
"fix": f"db/connectors/ticket assign {t['id']} <agent>",
|
||||
})
|
||||
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
|
||||
issues.append({
|
||||
"type": "stale_backlog",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} still in backlog (unblocked, never started)",
|
||||
"detail": f"#{t['id']} stale backlog",
|
||||
"fix": f"db/connectors/ticket status {t['id']} in_progress",
|
||||
})
|
||||
if t["status"] == "done" and t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "assigned_but_done",
|
||||
"ticket_id": t["id"],
|
||||
"detail": f"#{t['id']} is done but still assigned to {t['assigned_to']}",
|
||||
"detail": f"#{t['id']} done, still assigned",
|
||||
"fix": f"db/connectors/ticket unassign {t['id']}",
|
||||
})
|
||||
|
||||
@@ -584,11 +580,9 @@ def cmd_sweep(args):
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"sprint": {
|
||||
"id": sprint["id"],
|
||||
"name": sprint.get("name", f"Sprint {sprint['id']}"),
|
||||
"status": sprint["status"],
|
||||
"goal": sprint.get("goal", ""),
|
||||
},
|
||||
"progress": {"total": total, "done": done, "pct": pct},
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,99 @@
|
||||
# Sprint 18: Touch — Client Tasks
|
||||
|
||||
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
|
||||
|
||||
**Branch:** `client`
|
||||
**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA)
|
||||
|
||||
## Carry-over from Sprint 17
|
||||
|
||||
None. Sprint 17 closed 19/19.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #151 | Minimap rendering | #148/#149 (both done in Sprint 17) |
|
||||
| #174 | Dialogue UI — client (D-061 spec) | #434 (done) |
|
||||
| #264 | Knowledge/journal display | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-020 (client is pure renderer, no game logic in GDScript), D-041 (knowledge graph — client displays KG data from snapshot), D-042 (UI microcopy format — YAML via UIStrings autoload)
|
||||
- `decisions/perception.md` — D-013 (diegetic insert/POI system — minimap is insert-layer UI), D-061 (dialogue box spec — 20% max height, no portraits)
|
||||
- `decisions/content.md` — D-028 (dialogue architecture — client renders options, server selects), D-062 (invisible locked options), D-064 (walk-away — WASD during dialogue)
|
||||
- `decisions/scope.md` — D-027 (vertical slice — dual-character POI and knowledge display)
|
||||
|
||||
## Notes
|
||||
|
||||
### #151 — Minimap rendering
|
||||
|
||||
POI data infrastructure (`PointOfInterest` component, discovery events) landed on the server in Sprint 17 (#148, #149). The `ObserverSnapshot` will carry POI data for discovered points. This ticket wires that data into a rendered minimap overlay.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `MinimapRenderer` scene or node attached to the insert HUD layer (z-layer 6, diegetic insert per D-049)
|
||||
- Nearby POIs rendered as colored dots at their relative compass position from player origin
|
||||
- Distant POIs (beyond minimap radius) rendered as directional arrows at the minimap border
|
||||
- POI dot color and shape vary by category (the server sends `poi_category` in snapshot — use it)
|
||||
- Player is always centered; minimap does not scroll or rotate (fixed-north, D-015)
|
||||
- Minimap must be diegetically framed — it reads as a neural insert overlay, not a traditional game HUD
|
||||
- If no POIs discovered: minimap is empty but the insert frame still renders (the frame is diegetic, always present)
|
||||
|
||||
Existing infrastructure to build on:
|
||||
- `client/scripts/autoloads/game_state.gd` — holds `current_snapshot` which will include POI array from server
|
||||
- `client/scripts/autoloads/ui_strings.gd` — minimap label strings (add to `client/data/ui-strings.yaml` per D-042)
|
||||
- `client/scripts/rendering/world_renderer.gd` — reference for how snapshot data drives rendered output
|
||||
|
||||
Gotcha: POI positions are in simulation tile coordinates. The minimap renders relative compass direction and distance, not absolute tile positions. Convert server tile positions to player-relative vectors in GDScript.
|
||||
|
||||
### #174 — Dialogue UI — client (implement to D-061 spec)
|
||||
|
||||
The dialogue box spec (#434, D-061) was delivered in Sprint 7 and is done. This ticket (#174) is the older "Dialogue UI" story whose description has been updated to reference the D-061 spec. Given that the dialogue box, response selection, and walk-away mechanic are already implemented (#434, #435, #437), this ticket now covers the remaining dialogue UI surface not yet wired.
|
||||
|
||||
What this ticket must deliver — audit first, then implement gaps:
|
||||
- Verify the existing dialogue box correctly uses `game_state.current_dialogue` field (set in Sprint 14+)
|
||||
- **Examine result display**: the examine verb (#242 server) returns a character-filtered text description. The client needs a display path for this — it should appear as a non-interactive overlay (not a dialogue box, no options), floating above the examined entity or in a dedicated "observation" panel. Design to spec: brief, diegetic, auto-dismisses after 4-6 seconds
|
||||
- **Dialogue UI hardening**: confirm invisible locked options (D-062) — no grayed-out elements, no lock icons anywhere in the dialogue tree
|
||||
- **Confrontation styling** (#436, D-063): verify italic first-person voice for confrontation options is rendering correctly; confirm the 1-2 second pre-delivery monologue beat fires before the option triggers
|
||||
|
||||
Integration point: `client/scripts/autoloads/game_state.gd` holds `current_dialogue`. The rendering layer reads this each frame. Examine result will come through a new `current_examine_result` field (coordinate field name with server team).
|
||||
|
||||
### #264 — Knowledge/journal display
|
||||
|
||||
Client UI for reviewing accumulated KG facts. The player character's `KnowledgeGraph` is populated server-side and sent down in snapshot as a structured object. This ticket creates the review panel.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A journal/insert panel — toggle key (TBD, coordinate with server team for any keybind — likely `J` or dedicated insert shortcut)
|
||||
- Displays accumulated facts grouped by entity: "What I know about Kael Davan", then fact entries with confidence level and source
|
||||
- Fact entries show: fact text, `KnowledgeConfidence` level (Suspects / KnowsOf / KnowsDetails / Direct), source (`DirectObservation` / `ToldBy` / `Heard`), and `last_observed_tick` timestamp converted to game-time string
|
||||
- `Contradicted` facts rendered with a visual distinction (strikethrough or amber tint) — these are the moments where THE FRIEND arc surfaces in the UI
|
||||
- `Stale` facts rendered more dimly than `Active` facts
|
||||
- The display is read-only — no player interaction with entries beyond scrolling
|
||||
- Diegetic frame: the panel reads as neural insert memory recall. Use `UIStrings` (D-042) for all labels (`client/data/ui-strings.yaml`)
|
||||
|
||||
Integration with `game_state.gd`: the snapshot does not currently carry a full KG dump — coordinate with server team. The server team will need to add a `player_knowledge` field to `ObserverSnapshot` (or a separate periodic message). Define the wire format jointly before implementation.
|
||||
|
||||
Key gotcha: the journal panel must close when dialogue opens and vice versa — they cannot be open simultaneously. Both compete for insert-layer attention.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#151 (minimap) → POI data in snapshot (#148/#149 done) — start immediately
|
||||
|
||||
#174 (dialogue UI hardening + examine result display) → examine field from server #242
|
||||
→ coordinate wire format week 1, implement week 2
|
||||
|
||||
#264 (knowledge/journal display) → player_knowledge field in snapshot (coordinate with server)
|
||||
→ start design week 1, implement after wire format agreed
|
||||
```
|
||||
|
||||
Parallel tracks: #151 can start immediately. #174 and #264 both need a brief coordination with server team on wire format additions — block 30 minutes in week 1 to agree those field names, then implement in parallel.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Sprint 18: Touch — Copy Tasks
|
||||
|
||||
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
|
||||
|
||||
**Branch:** `copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative lead), Gestalt (systems)
|
||||
|
||||
## Carry-over from Sprint 17
|
||||
|
||||
None. Sprint 17 closed 19/19.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #544 | Design: collision-resistant line IDs for auto-generated NPCs | — |
|
||||
| #158 | Tier 1 drama module schema | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/content.md` — D-023 (three-tier content model — Tier 1 is drama modules), D-024 (NPC generation — hundreds of auto-generated NPCs per D-029 population model), D-028 (dialogue line pool format), D-035 (line ID scheme — Amendment Sprint 15: NPC-scoped IDs; D-029 auto-generated NPCs need collision-resistant variant)
|
||||
- `decisions/scope.md` — D-027 (vertical slice scope — Tier 1 modules activate for the smuggler/detective scenario)
|
||||
|
||||
## Notes
|
||||
|
||||
### #544 — Design: collision-resistant line IDs for auto-generated NPCs
|
||||
|
||||
**Context (Q-028):** The current line ID scheme (D-035 Amendment Sprint 15) uses `{npc-slug}_{d|m}_{###}` — e.g., `kael-davan_d_001`. For hand-authored NPCs with unique slugs this works. But D-029 specifies hundreds of procedurally generated NPCs (the 70% mundane majority), each with a generated slug like `dock-worker`. A district with 40 dock workers all using `dock-worker_d_001` produces immediate collision.
|
||||
|
||||
**What this ticket must deliver:**
|
||||
- A design document (output to `docs/design/` or as a decision record) specifying the collision-resistant scheme for auto-generated NPC line IDs
|
||||
- Evaluation of at least three options:
|
||||
1. **Short UUID suffix on NPC slug** — `dock-worker-a3f2_d_001`. Human-readable, unique per NPC, but IDs are not stable across seed changes
|
||||
2. **StableId prefix** — `npc-00042_d_001`. Machine-readable, stable if `StableId` persists. Less author-friendly
|
||||
3. **Role slug + instance counter** — `dock-worker-03_d_001`. Human-readable, author can write role-scoped lines used by all instances of that role. Requires a clear definition of "role" as the ID namespace
|
||||
4. **Slug registry with collision resolution** — same slug gets `dock-worker`, `dock-worker-2`, etc. at generation time, recorded in content registry
|
||||
|
||||
- The chosen scheme must satisfy:
|
||||
- Hand-authored NPCs (Kael, Sera, etc.) retain their current human-readable slugs — no migration
|
||||
- Auto-generated NPCs can be distinguished from one another in line IDs
|
||||
- The scheme is implementable in the content registry (`server/src/knowledge/registry.rs`) without breaking existing authored content
|
||||
- Authors can still write role-scoped lines that apply to all dock workers (shared content), distinct from instance-specific authored lines
|
||||
|
||||
- **Gestalt owns this ticket** — it resolves Q-028. Output: a decision record (D-NNN) or documented convention added to `docs/design/`. Once the scheme is chosen, update `docs/design/interaction-verbs-v0.1.md` or the content authoring guide if needed.
|
||||
|
||||
Timeline: resolve by end of week 1 so server/ci teams can implement the registry change in Sprint 18 or Sprint 19.
|
||||
|
||||
### #158 — Tier 1 drama module schema
|
||||
|
||||
Tier 1 content (D-023) is authored drama modules drawn from a pool at game start. The smuggler/detective vertical slice (D-027) IS a Tier 1 module. This ticket defines the structure every Tier 1 module must follow so the storyteller can activate and manage them consistently.
|
||||
|
||||
**What this ticket must deliver:**
|
||||
- A YAML schema definition file at `content/schemas/drama_module.schema.yaml` (or extend existing schema files in `content/schemas/`)
|
||||
- The schema must cover:
|
||||
- **Entry conditions**: what world-state must be true for this module to be activatable (NPC present, player relationship threshold, location accessible, etc.)
|
||||
- **NPC requirements**: which NPC slots the module requires (protagonist, antagonist, witness, etc.) and what axes they must satisfy (e.g., "protagonist must have Major secret")
|
||||
- **Event sequences**: ordered or unordered events the module can fire, with storyteller trigger conditions for each (proximity, tick threshold, player action)
|
||||
- **Outcomes**: resolution states the module can reach (exposed, escaped, ambiguous, abandoned)
|
||||
- **Pool format**: how multiple modules coexist in the pool — each module is a YAML file in `content/modules/tier1/`
|
||||
- A stub Tier 1 module file for the smuggling ring scenario at `content/modules/tier1/smuggling_ring_v0_1.yaml` — this is the vertical slice module
|
||||
- Coordinate with server team: the storyteller module stub (`server/src/storyteller/`) will eventually activate modules by reading this schema. Schema design choices constrain implementation — agree on the top-level structure with Tyre before finalizing
|
||||
|
||||
**Paula owns the dramatic structure design** (entry conditions, event sequences, outcomes). **Gestalt owns the schema implementation** (YAML format, field names, validation rules). **Mellanie reviews** for authoring ergonomics — can a writer actually fill this template?
|
||||
|
||||
Output: schema file + stub module file + brief authoring notes in `docs/design/tier1-module-authoring.md` explaining the fields to future writers.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#544 (line ID design) → standalone — Gestalt starts week 1
|
||||
→ output unblocks server/ci registry implementation (Sprint 19)
|
||||
|
||||
#158 (drama module schema) → standalone — Paula + Gestalt start week 1
|
||||
→ output unblocks storyteller activation (future sprint)
|
||||
```
|
||||
|
||||
Both tickets are design-first — produce documents and schema files, not code. Both run in parallel from day 1.
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
- **Q-028: collision-resistant line IDs** — #544 IS the resolution ticket. Gestalt must produce a concrete decision by end of week 1. The decision should be registered as D-NNN via the standard decision record format in `decisions/content.md`.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(copy): description" --description "body" --base main --head copy
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Sprint 18: Touch — Joint Tasks
|
||||
|
||||
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
|
||||
|
||||
## Pre-Sprint
|
||||
|
||||
No blocking decisions required before implementation starts. All architectural decisions for Sprint 18 scope are confirmed.
|
||||
|
||||
| Decision | Status | Impact |
|
||||
|----------|--------|--------|
|
||||
| D-011 (NPCs use same LOS system) | Confirmed | #115 (NPC vision) — no new LOS mechanism needed |
|
||||
| D-026 (simulation tiers) | Confirmed | #95 (background state machines) — `BackgroundSim` marker exists |
|
||||
| D-041 (knowledge graph data model) | Confirmed | #242 (examine), #264 (journal display) — KG is the output format |
|
||||
| D-062 (invisible locked options) | Confirmed | #174 (dialogue UI audit) — no grayed-out options anywhere |
|
||||
| D-035 Amendment Sprint 15 (NPC-scoped line IDs) | Confirmed | #544 (ID design) extends this for auto-generated NPCs |
|
||||
| Q-028 (collision-resistant line IDs) | **Open** | #544 resolves this — copy team, week 1 |
|
||||
|
||||
## Cross-Team Integration Points
|
||||
|
||||
### Server → Client wire format additions (week 1 coordination)
|
||||
|
||||
Two new snapshot fields must be agreed before client implementation begins:
|
||||
|
||||
| New field | Server ticket | Client ticket | Format notes |
|
||||
|-----------|--------------|---------------|--------------|
|
||||
| `examine_result` | #242 | #174 | `{entity_id, text, confidence}` or null — character-filtered observation text |
|
||||
| `player_knowledge` | #256 (stub) / #242 | #264 | Partial KG dump: `{entities: [{id, name, confidence, source, state, last_tick}], facts: [...]}` |
|
||||
| `poi_list` | #148/#149 (done) | #151 | Already in snapshot from Sprint 17 — verify field name with server |
|
||||
|
||||
Action: server team (Tyre/Dudley) and client team (Stig) align on field names and wire format on **day 1**. No code needed — just agreed field names written to a `.tmp/` file or directly into `server/src/bridge/types.rs` as stub structs.
|
||||
|
||||
### Server → Copy dependency
|
||||
|
||||
| Server ticket | Copy output | Integration |
|
||||
|---------------|-------------|-------------|
|
||||
| #91 (skill system) | #544 (ID scheme) | Auto-generated NPC skills will need line IDs once the NPC pool is large. ID scheme must work for skill-bearing generated NPCs. |
|
||||
| #115 (NPC vision) | #248 (pressure framework) | Awareness events from #244 drive `exposure_pressure` in #248 — copy team's monologue lines with `mood: [anxious]` are the output surface. |
|
||||
|
||||
### Copy → Server dependency
|
||||
|
||||
| Copy output | Server ticket | Integration |
|
||||
|-------------|--------------|-------------|
|
||||
| #544 (ID scheme decision) | Content registry | Server team cannot implement collision-resistant registry until scheme is decided. Sprint 19 work. |
|
||||
| #158 (drama module schema) | Storyteller activation | Schema design constrains the storyteller module interface. Server team should review #158 output before storyteller implementation begins. |
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
Sprint 18 is **DONE** when:
|
||||
|
||||
1. **Examine fires and is character-filtered** — Player uses Examine on an NPC at close range. The client displays a short observation text. The smuggler and detective receive different text for the same NPC — same entity, different perspective.
|
||||
2. **NPC notices the player** — An NPC with the player in its LOS for ≥N consecutive ticks changes behavior: route deviation, posture shift, or tell state update. The player can observe this response.
|
||||
3. **Social ripple is observable** — Player action toward NPC A (e.g., a trust-positive dialogue) causes a measurable trust delta on NPC B (second-order relationship). Verify via server state inspection or the WRONG button (F12) snapshot.
|
||||
4. **Tell states wire to snapshot** — Active-tier NPCs with Major secrets show `Nervous` or `Guarded` tell in the `ObserverSnapshot`. The client renders this (monologue trigger or entity tint — verify whichever is wired).
|
||||
5. **Minimap renders POIs** — Discovered POIs appear as dots/arrows on the insert minimap overlay. Player position is centered. Minimap is present when at least one POI has been discovered.
|
||||
6. **Journal panel opens** — Player opens journal panel. At least one KG fact entry is visible with confidence level, source, and game-time timestamp.
|
||||
7. **Background NPCs tick** — A Background-tier NPC's schedule, mood, relationship, and job state visibly change over game-time (verify via WRONG button or server log). Tick rate is once per game-minute (10 ticks per D-031).
|
||||
8. **Skill system exists** — Generated NPCs have a `SkillSet` component. At least one NPC is spawned with `combat_trained` skill, resulting in a `CombatCapability` marker component attached.
|
||||
9. **Save data model stubs round-trip** — A `SaveStateV1` struct serializes and deserializes without data loss. Test coverage confirms roundtrip fidelity for: entity positions, KG entries, relationship graph, simulation tick.
|
||||
10. **Line ID scheme is decided** — Q-028 is resolved. A decision record (D-NNN) exists in `decisions/content.md`. The scheme is documented clearly enough for a writer to apply it immediately.
|
||||
|
||||
## Test Plan (D-030 alignment)
|
||||
|
||||
Sprint 18 is in the **integration testing** phase (ongoing from Sprint 3 per D-030). The gauntlet infrastructure from Sprint 17 is the primary test harness for server-side verification.
|
||||
|
||||
| Ticket | Test approach |
|
||||
|--------|---------------|
|
||||
| #242 (examine) | Unit test: `process_examine_interaction` with mock KG → confirm character-filtered output differs between smuggler and detective KG states. Integration test: examine verb from player action → KG entry written via event queue. |
|
||||
| #244 (NPC awareness) | Unit test: NPC with player in LOS for N ticks → `PlayerAwareness` component threshold crossed → routine deviation fired. |
|
||||
| #248 (pressure framework) | Unit test: high awareness events → `exposure_pressure` rises. Integration: pressure visible in snapshot HUD data. |
|
||||
| #249 (social propagation) | Unit test: trust delta +5 to NPC A → NPC B (strong relationship to A) receives delta ~+2. Cycle test: A→B→A propagation terminates cleanly. |
|
||||
| #337 (tell state wiring) | Integration test: `TellCategory::Nervous` for NPC with Major secret + stress past midpoint → confirmed in snapshot `entities[].tell_state`. |
|
||||
| #91 (skill system) | Unit test: NPC with `combat_trained` skill in `SkillSet` → `CombatCapability` component present after spawn. Unit test: NPC without `combat_trained` → no `CombatCapability`. |
|
||||
| #115 (NPC vision) | Unit test: NPC placed adjacent to player (within LOS, no walls) → `NpcVisionState` contains player `StableId`. Wall-blocked: player not visible. |
|
||||
| #256 (save state) | Unit test: serialize `SaveStateV1` with known state → deserialize → all fields match. Round-trip for `KnowledgeGraph` (already serializable). |
|
||||
| #95 (background ticks) | Unit test: NPC with `BackgroundSim`, advance 10 ticks → schedule state machine advances. Unit test: mood drift toward neutral after 10 ticks. |
|
||||
| #151 (minimap) | Manual: POI discovered → minimap dot appears. Distant POI: directional arrow appears at minimap border. |
|
||||
| #174 (dialogue UI) | Manual: examine result appears as overlay, auto-dismisses. Dialogue options confirmed: no locked/grayed options visible. Confrontation option in italic voice. |
|
||||
| #264 (journal) | Manual: journal panel opens, KG facts listed with correct metadata. Contradicted facts visually distinct. |
|
||||
| #544 (ID scheme) | Decision review: scheme handles all four population categories (hand-authored, role-based, generated unique, generated shared). |
|
||||
| #158 (drama module schema) | Schema review: stub `smuggling_ring_v0_1.yaml` validates against `drama_module.schema.yaml`. |
|
||||
|
||||
## Teams
|
||||
|
||||
| Team | Branch | Agents | Tickets |
|
||||
|------|--------|--------|---------|
|
||||
| server | `server` | Dudley, Tyre, Hoshe | #242, #244, #248, #249, #337, #91, #115, #256, #95 |
|
||||
| client | `client` | Stig, Tyre, Hoshe | #151, #174, #264 |
|
||||
| copy | `copy` | Mellanie, Paula, Gestalt | #544, #158 |
|
||||
@@ -0,0 +1,175 @@
|
||||
# Sprint 18: Touch — Server Tasks
|
||||
|
||||
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
|
||||
|
||||
**Branch:** `server`
|
||||
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
|
||||
|
||||
## Carry-over from Sprint 17
|
||||
|
||||
None. Sprint 17 closed 19/19.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #242 | Examine mechanic | #240 (done) |
|
||||
| #244 | NPC player-awareness behavior | — |
|
||||
| #248 | Character goal/pressure framework | — |
|
||||
| #249 | Player-action social propagation | — |
|
||||
| #337 | Tell state derivation system | #323 (done) |
|
||||
| #91 | Skill system & combat flag | — |
|
||||
| #115 | NPC vision system | — |
|
||||
| #256 | Save state data model | — |
|
||||
| #95 | Background tier state machines | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-010 (information boundaries), D-020 (IPC architecture), D-026 (simulation tiers), D-041 (knowledge graph data model)
|
||||
- `decisions/perception.md` — D-011 (fog of perception — NPCs use same LOS), D-035 (symmetric shadowcasting)
|
||||
- `decisions/content.md` — D-024 (NPC 10-axis model, skill set axis, combat component), D-028 (dialogue architecture — examine verb is a dialogue layer entry point)
|
||||
- `decisions/scope.md` — D-027 (vertical slice criteria — character-specific observation)
|
||||
|
||||
## Notes
|
||||
|
||||
### #242 — Examine mechanic
|
||||
|
||||
The interaction dispatcher (`server/src/simulation/interaction.rs`) already computes `VerbKind::ExamineNpc` and `VerbKind::ExamineObject` in the `NearbyInteractionBuffer`. The examine verb appears at close range (≤2 tiles, `CLOSE_RANGE`). `PlayerAction` dispatch and `process_player_input` are the entry points in `server/src/simulation/input.rs`.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `process_examine_interaction` system that handles `PlayerAction::Examine { entity_id }` (or equivalent)
|
||||
- Generates a detailed `ObservationEvent` with low uncertainty for the target entity
|
||||
- Applies character-specific filtering via the observer's `KnowledgeGraph` (same NPC looks different to smuggler vs detective — smuggler reads cargo-handling posture, detective reads procedural tells)
|
||||
- Result is written to the observer's `KnowledgeGraph` via `KnowledgeEventQueue` as a `DirectObservation` entry with `KnowledgeConfidence::Direct`
|
||||
- Emits an examine result field in `ObserverSnapshot` so the client can display character-filtered detail text
|
||||
|
||||
Integration points: `server/src/simulation/interaction.rs` (verb dispatch), `server/src/knowledge/graph.rs` (`KnowledgeGraph` write), `server/src/perception/observation.rs` (observation event pattern), `server/src/simulation/dialogue.rs` (examine result mirrors dialogue result pattern).
|
||||
|
||||
### #244 — NPC player-awareness behavior
|
||||
|
||||
NPCs use the same LOS system as the player (D-011). The awareness system detects when an NPC's LOS query includes the player's `TilePosition`, and generates a behavioral response.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A new `PlayerAwareness` component on Active-tier NPCs tracking: whether the player is in this NPC's LOS, for how many consecutive ticks, and accumulated suspicion level
|
||||
- A `detect_player_awareness` system running after `compute_observer_snapshot` — iterate Active-tier NPCs, run a simplified LOS check or piggyback on existing shadowcast state
|
||||
- When awareness crosses threshold: routine deviation behavior (NPC changes path or posture), fed into the `DerivedTellState` pipeline (already exists in `server/src/npc/tell_state.rs`)
|
||||
- Feeds follow-verb suspicion in `server/src/simulation/follow.rs`
|
||||
|
||||
Key file: `server/src/simulation/follow.rs` already has proximity + attention logic for follow suspicion — awareness system reuses this infrastructure. New system lives in `server/src/simulation/` or `server/src/npc/`.
|
||||
|
||||
### #248 — Character goal/pressure framework
|
||||
|
||||
Defines systemic pressures per character that modulate monologue salience and observation priority. Not scripted arcs — emergent from interaction of existing axes (D-024).
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `CharacterPressure` component on the player character entity: `exposure_pressure: i32` (smuggler), `institutional_pressure: i32` (detective), `relationship_pressure: i32` (both)
|
||||
- Pressure inputs: exposure rises when NPCs notice the player (feeds from #244), relationship pressure from trust changes in `server/src/npc/relationships.rs`, institutional pressure from detective-specific interaction patterns
|
||||
- Pressure outputs: written into `ObserverSnapshot` HUD widget data; high pressure raises monologue trigger weight for anxiety-tagged lines
|
||||
- Coordinate with copy team — monologue lines using `mood: [anxious]` or `mood: [frustrated]` tags (D-035) are the output surface
|
||||
|
||||
This is a design-and-implement ticket — start by defining the pressure struct, then wire inputs from existing systems. Monologue salience weighting is the primary v0.1 output.
|
||||
|
||||
### #249 — Player-action social propagation
|
||||
|
||||
Player actions toward one NPC ripple through the relationship graph at three decay orders (D-029 topology principle). The `RelationshipGraph` resource (`server/src/npc/relationships.rs`) and `TrustEventQueue` are the integration points.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `propagate_social_actions` system triggered when a `TrustEventQueue` event fires from player action
|
||||
- First-order: immediate full delta to the directly affected NPC
|
||||
- Second-order: `delta * 0.4` to NPCs with strong relationships to the first-order NPC (trust > 3 in `RelationshipGraph`)
|
||||
- Third-order: `delta * 0.15` to NPCs one further hop away, delayed by configurable ticks
|
||||
- Propagation topology varies per seed (D-029 anti-metagaming) — the same action produces different cascades depending on who knows whom
|
||||
- Write propagated trust changes back to `TrustEventQueue` or directly to `Relationships` components with a `PropagatedTrust` marker
|
||||
|
||||
Gotcha: propagation must not loop (A affects B affects A). Visited-entity set per propagation pass prevents cycles.
|
||||
|
||||
### #337 — Tell state derivation system
|
||||
|
||||
The `derive_tell_state` system already exists and is fully tested in `server/src/npc/tell_state.rs`. This ticket existed in the backlog because the mood state machine (#323) it depends on was not yet done. #323 is now done.
|
||||
|
||||
What this ticket must deliver:
|
||||
- Verify `derive_tell_state` runs correctly in the current schedule (it is already registered in `server/src/npc/mod.rs` after `mood::update_mood`)
|
||||
- Wire `DerivedTellState` into the observer snapshot output — confirm `ObserverSnapshot.entities[].tell_state` is populated for visible entities
|
||||
- Integration test: NPC with Major secret + stress past midpoint shows `TellCategory::Nervous` in snapshot
|
||||
- This ticket is mostly verification + integration wiring, not new code — the system is complete, the sprint task is closing the loop into the snapshot
|
||||
|
||||
### #91 — Skill system & combat flag
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `SkillSet` component: `BTreeMap<String, u8>` of named skills with level values (BTreeMap per D-010 determinism requirement)
|
||||
- When a `SkillSet` contains `"combat_trained"` with value ≥ 1, the ECS system attaches a `CombatCapability` marker component to that NPC at spawn time
|
||||
- `SkillSet` added to the NPC generation pipeline in `server/src/npc/generate.rs` (already sets other D-024 axes)
|
||||
- The `CombatCapability` component is a zero-sized marker for now — future sprints add stats
|
||||
|
||||
NPC skill sets are generated from content YAML at startup. The content loader in `server/src/content/` reads NPC definitions — add `skills: {}` as a YAML field on NPC templates.
|
||||
|
||||
### #115 — NPC vision system
|
||||
|
||||
NPCs must use the same LOS shadowcasting system as the player (D-011 — "Applies to ALL entities"). The shadowcast machinery lives in `server/src/perception/shadowcast.rs`.
|
||||
|
||||
What this ticket must deliver:
|
||||
- NPC vision is computed via `compute_los` (or equivalent call) for Active-tier NPCs each tick
|
||||
- Results stored in an `NpcVisionState` component: set of `StableId` values currently visible to this NPC, plus the player entity if visible
|
||||
- NPC memory: `NpcMemory` component tracking last-known-position of the player even after leaving LOS ("saw you enter building → knows you're inside" per D-011)
|
||||
- Inference stub: if player was seen entering a room, NPC `KnowledgeGraph` records `DirectObservation` of player at that room's zone, degrading to `KnowsOf` after configurable ticks
|
||||
|
||||
This feeds #244 (awareness) — the `detect_player_awareness` system reads `NpcVisionState` rather than running its own LOS query.
|
||||
|
||||
Performance note: only run LOS for NPCs whose `TilePosition` is within `ACTIVE_RADIUS` (already guaranteed by `ActiveSim` marker). Full shadowcast per NPC per tick is feasible at 30-80 active NPCs — Tyre has confirmed the budget.
|
||||
|
||||
### #256 — Save state data model
|
||||
|
||||
Define the serialization format for full game state. Shares architecture with #96 (state serialization system, still backlog — this ticket is the data model design, not the save/load implementation).
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `SaveStateV1` struct (versioned from day one) covering: entity state, `KnowledgeGraph` per entity (already serializable via `serde` in `server/src/knowledge/graph.rs`), `RelationshipGraph`, game clock position (`SimulationTime`), seed value
|
||||
- Write format: MessagePack (consistent with IPC protocol per D-020) or RON for human-readable debugging — decide and document
|
||||
- The struct must roundtrip cleanly: serialize + deserialize produces identical ECS world state
|
||||
- Stub tests proving the roundtrip; full save/load flow is #257 (future sprint)
|
||||
|
||||
The `KnowledgeGraph` is already `Serialize + Deserialize`. The main design work is enumerating which ECS components must be captured and in what order (deterministic serialization per D-010).
|
||||
|
||||
### #95 — Background tier state machines
|
||||
|
||||
Background-tier NPCs (marked `BackgroundSim` in `server/src/simulation/tier.rs`) currently receive no simulation — tier markers exist but no background tick systems run. This ticket adds the four D-026 state machines for background NPCs.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `background_tick` system gated by `With<BackgroundSim>` that fires once per game-minute (every 10 ticks per D-031)
|
||||
- Four mini state machines per background NPC:
|
||||
1. **Schedule**: advance NPC to next routine activity based on `DayPhase` (reads `DayPhase` from `server/src/simulation/time.rs`, updates `Routine` component)
|
||||
2. **Mood**: simple mood drift toward neutral; significant events (stress > threshold) can shift from neutral
|
||||
3. **Relationships**: trust drift toward baseline over time; no events-driven trust changes for background NPCs
|
||||
4. **Job**: job performance score drift based on contentment (lower contentment → lower performance)
|
||||
- Background tick does NOT run pathfinding, LOS, or dialogue — those are Active-tier only
|
||||
- Background NPCs promoted to Active receive their current state machine state (no reset on promotion)
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#95 (background tier state machines) → standalone, no blockers
|
||||
|
||||
#115 (NPC vision system) → #244 (player-awareness behavior)
|
||||
↓
|
||||
#248 (character goal/pressure framework) ← feeds from awareness events
|
||||
|
||||
#337 (tell state wiring) → standalone, verify + wire into snapshot
|
||||
|
||||
#242 (examine mechanic) → standalone (dispatcher already exists)
|
||||
|
||||
#249 (social propagation) → standalone (relationships already exist)
|
||||
|
||||
#91 (skill system & combat flag) → standalone
|
||||
|
||||
#256 (save state data model) → standalone (design + stub)
|
||||
```
|
||||
|
||||
Parallel tracks: #95, #91, #337, #256, #249, and #242 can all start in week 1. #244 starts after #115 is in review.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
|
||||
```
|
||||
@@ -178,6 +178,10 @@ impl Plugin for BridgePlugin {
|
||||
.after(crate::simulation::sound::collect_sound_events)
|
||||
.after(crate::simulation::conversation::run_npc_conversations)
|
||||
.after(crate::simulation::dialogue::process_walk_away),
|
||||
// Contradiction monologue fires from queue populated by prior tick's
|
||||
// process_knowledge_events (which runs after the snapshot).
|
||||
crate::simulation::monologue::process_contradiction_monologue
|
||||
.after(crate::simulation::monologue::trigger_event_monologue),
|
||||
crate::simulation::follow::update_follow_state
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
@@ -185,7 +189,7 @@ impl Plugin for BridgePlugin {
|
||||
crate::perception::observer::compute_observer_snapshot
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.after(crate::simulation::interaction::compute_nearby_interactions)
|
||||
.after(crate::simulation::monologue::trigger_event_monologue)
|
||||
.after(crate::simulation::monologue::process_contradiction_monologue)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
|
||||
@@ -20,6 +20,8 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::knowledge::ContentEntityRegistry;
|
||||
|
||||
/// Configuration for the content loader.
|
||||
/// Set the content root path before adding ContentPlugin.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@@ -52,6 +54,10 @@ impl Plugin for ContentPlugin {
|
||||
app.insert_resource(ContentConfig::default());
|
||||
}
|
||||
|
||||
// ContentEntityRegistry is required by spawn_npc (D-079).
|
||||
// Init here so ContentPlugin works standalone without KnowledgePlugin.
|
||||
app.init_resource::<ContentEntityRegistry>();
|
||||
|
||||
app.add_systems(Startup, load_and_spawn_content);
|
||||
app.add_systems(PostUpdate, hot_reload::hot_reload_content);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::content::loader::{ContentStore, DistrictContent};
|
||||
use crate::content::types;
|
||||
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::knowledge::types::{
|
||||
@@ -213,6 +214,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
ContentSlug(profile.canonical_id.clone()),
|
||||
));
|
||||
|
||||
// Register in ContentEntityRegistry so KnowledgeGrant::Entity can resolve entity_ref strings
|
||||
// (D-079: ContentEntityRegistry populated at NPC spawn time)
|
||||
world
|
||||
.resource_mut::<ContentEntityRegistry>()
|
||||
.register(profile.canonical_id.clone(), stable_id);
|
||||
|
||||
result
|
||||
.npc_ids
|
||||
.insert(profile.canonical_id.clone(), stable_id);
|
||||
@@ -296,6 +303,7 @@ fn resolve_information(
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -648,6 +656,7 @@ mod tests {
|
||||
fn create_test_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ContentEntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
|
||||
@@ -491,10 +491,30 @@ pub struct DialogueLine {
|
||||
pub knowledge_grant: Option<KnowledgeGrant>,
|
||||
}
|
||||
|
||||
/// Knowledge grant attached to a dialogue line (D-079).
|
||||
///
|
||||
/// Untagged enum — serde tries each variant in order:
|
||||
/// `Fact` matches YAML with `fact_id` field.
|
||||
/// `Entity` matches YAML with `entity_ref` field.
|
||||
/// `Compound` variant deferred to Sprint 18.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KnowledgeGrant {
|
||||
pub fact_id: String,
|
||||
pub confidence: String,
|
||||
#[serde(untagged)]
|
||||
pub enum KnowledgeGrant {
|
||||
/// Grant knowledge of a non-entity fact.
|
||||
/// Format: fact_id "category.topic", confidence string.
|
||||
Fact {
|
||||
fact_id: String,
|
||||
confidence: String,
|
||||
},
|
||||
/// Grant knowledge of an entity (creates EntityKnowledge entry in observer's KG).
|
||||
/// Required for contradiction detection: testimony must create ToldBy EntityKnowledge
|
||||
/// so a subsequent DirectObservation can detect a discrepancy (D-079, D-083).
|
||||
Entity {
|
||||
entity_ref: String,
|
||||
#[serde(default)]
|
||||
attributes: BTreeMap<String, String>,
|
||||
confidence: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -517,6 +537,9 @@ pub struct MonologueLine {
|
||||
pub prerequisites: Option<Prerequisites>,
|
||||
#[serde(default)]
|
||||
pub priority: Option<i32>,
|
||||
/// Per-line cooldown in ticks. `None` (omitted in YAML) means no per-line
|
||||
/// cooldown — fire-once lines rely on trigger semantics instead (e.g.,
|
||||
/// `first_*` and `contradiction_detected` triggers fire once by design).
|
||||
#[serde(default)]
|
||||
pub cooldown: Option<i32>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! ContentEntityRegistry resource (D-079).
|
||||
//!
|
||||
//! Maps NPC canonical_id strings (e.g., "kael-davan") to their runtime StableIds.
|
||||
//! Populated at NPC spawn time; queried at KnowledgeGrant processing time to
|
||||
//! resolve `entity_ref` strings in `KnowledgeGrant::Entity` variants.
|
||||
//!
|
||||
//! BTreeMap for deterministic iteration (D-010 principle 4).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::types::StableId;
|
||||
|
||||
/// Content ID → StableId registry.
|
||||
///
|
||||
/// Populated by `spawn_npc` for every authored NPC. Read by the
|
||||
/// `KnowledgeGranted` event handler to resolve `entity_ref` strings at
|
||||
/// grant processing time (D-079).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ContentEntityRegistry {
|
||||
entries: BTreeMap<String, StableId>,
|
||||
}
|
||||
|
||||
impl ContentEntityRegistry {
|
||||
/// Register a content_id → StableId mapping.
|
||||
///
|
||||
/// Idempotent for the same (content_id, stable_id) pair.
|
||||
/// If the same content_id is registered twice with different StableIds,
|
||||
/// the latest call wins (last-write semantics; warn in caller if this is unexpected).
|
||||
pub fn register(&mut self, content_id: impl Into<String>, stable_id: StableId) {
|
||||
self.entries.insert(content_id.into(), stable_id);
|
||||
}
|
||||
|
||||
/// Resolve a content_id string to a StableId.
|
||||
///
|
||||
/// Returns `None` if the entity_ref is not registered. Callers should
|
||||
/// emit `tracing::warn!` and drop the grant when `None` is returned.
|
||||
pub fn resolve(&self, entity_ref: &str) -> Option<StableId> {
|
||||
self.entries.get(entity_ref).copied()
|
||||
}
|
||||
|
||||
/// Number of registered entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn register_and_resolve() {
|
||||
let mut registry = ContentEntityRegistry::default();
|
||||
let sid = StableId(42);
|
||||
registry.register("kael-davan", sid);
|
||||
|
||||
assert_eq!(registry.resolve("kael-davan"), Some(sid));
|
||||
assert_eq!(registry.resolve("unknown-npc"), None);
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_overwrites() {
|
||||
let mut registry = ContentEntityRegistry::default();
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
registry.register("npc-x", sid_a);
|
||||
registry.register("npc-x", sid_b);
|
||||
|
||||
assert_eq!(registry.resolve("npc-x"), Some(sid_b));
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry() {
|
||||
let registry = ContentEntityRegistry::default();
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.resolve("any"), None);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! KnowledgeEvents; the processing system drains them per tick.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
@@ -11,6 +12,41 @@ use super::graph::KnowledgeGraph;
|
||||
use super::registry::EntityRegistry;
|
||||
use super::types::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Processed knowledge grant types (D-079)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Processed Fact grant — confidence string parsed to typed enum at creation time.
|
||||
/// Used in `KnowledgeEventType::KnowledgeGranted`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessedFactGrant {
|
||||
pub fact_id: FactId,
|
||||
pub confidence: KnowledgeConfidence,
|
||||
}
|
||||
|
||||
/// Processed Entity grant — entity_ref resolved to StableId at creation time.
|
||||
/// Used in `KnowledgeEventType::KnowledgeGranted`.
|
||||
///
|
||||
/// Creates an `EntityKnowledge` entry in the observer's KG with `ToldBy` source,
|
||||
/// enabling contradiction detection when a subsequent `DirectObservation` disagrees.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessedEntityGrant {
|
||||
pub target_id: StableId,
|
||||
pub attributes: BTreeMap<String, String>,
|
||||
pub confidence: KnowledgeConfidence,
|
||||
}
|
||||
|
||||
/// Typed knowledge grant payload — all string fields resolved at event creation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProcessedKnowledgeGrant {
|
||||
Fact(ProcessedFactGrant),
|
||||
Entity(ProcessedEntityGrant),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events that modify knowledge graphs. Produced by perception and
|
||||
/// other systems. Consumed by the knowledge update system.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -36,6 +72,16 @@ pub enum KnowledgeEventType {
|
||||
target: Entity,
|
||||
interaction_type: InteractionType,
|
||||
},
|
||||
/// Knowledge granted to observer via dialogue line selection (D-079).
|
||||
///
|
||||
/// Fires at line selection time in `process_talk_interaction`.
|
||||
/// Source is `ToldBy { source_id, tick }` for NPC testimony.
|
||||
/// For Fact grants, the granting NPC's KG must contain the fact (guardrail enforced
|
||||
/// at event creation time — event is only pushed if guardrail passes).
|
||||
KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant,
|
||||
source: KnowledgeSource,
|
||||
},
|
||||
}
|
||||
|
||||
/// Type of interaction for walk-away recording (D-064).
|
||||
@@ -57,6 +103,55 @@ pub struct KnowledgeEventQueue {
|
||||
pub(crate) events: Vec<KnowledgeEvent>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contradiction detection output (D-083)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Event emitted when `observe_entity()` detects a position contradiction
|
||||
/// between a `ToldBy` source and a `DirectObservation`.
|
||||
///
|
||||
/// Consumed by the monologue system (D-083 → monologue trigger) and
|
||||
/// potentially the storyteller. One event per detected contradiction per tick.
|
||||
///
|
||||
/// Display names are pre-resolved by `process_knowledge_events` via EntityRegistry
|
||||
/// and NpcName, so downstream consumers (monologue) are pure string consumers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContradictionDetectedEvent {
|
||||
/// The observer who detected the contradiction.
|
||||
pub observer: Entity,
|
||||
/// The entity whose position was contradicted.
|
||||
pub target: StableId,
|
||||
/// Full contradiction details (who told what, where observed, when).
|
||||
pub claim: ContradictionClaim,
|
||||
/// Pre-resolved display name of the NPC who told the false position (told_by source).
|
||||
pub source_display_name: String,
|
||||
/// Pre-resolved display name of the entity whose position was contradicted (target).
|
||||
pub subject_display_name: String,
|
||||
}
|
||||
|
||||
/// Resource: queue of contradictions detected this tick.
|
||||
///
|
||||
/// Populated by `process_knowledge_events` when `observe_entity()` returns
|
||||
/// a `ContradictionClaim`. Drained by downstream systems (monologue, storyteller).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ContradictionDetectedQueue {
|
||||
events: Vec<ContradictionDetectedEvent>,
|
||||
}
|
||||
|
||||
impl ContradictionDetectedQueue {
|
||||
pub fn push(&mut self, event: ContradictionDetectedEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<ContradictionDetectedEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl KnowledgeEventQueue {
|
||||
/// Push a knowledge event into the queue.
|
||||
pub fn push(&mut self, event: KnowledgeEvent) {
|
||||
@@ -82,9 +177,15 @@ impl KnowledgeEventQueue {
|
||||
/// System: process pending knowledge events.
|
||||
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
|
||||
/// to the relevant KnowledgeGraph components.
|
||||
///
|
||||
/// On contradiction detection (D-083):
|
||||
/// - Shifts the ToldBy source entity to PersonOfInterest in the observer's KG.
|
||||
/// - Pre-resolves display names for downstream monologue consumer.
|
||||
pub fn process_knowledge_events(
|
||||
mut queue: ResMut<KnowledgeEventQueue>,
|
||||
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
||||
registry: Res<EntityRegistry>,
|
||||
npc_names: Query<&crate::simulation::conversation::NpcName>,
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
let events = queue.drain();
|
||||
@@ -96,7 +197,59 @@ pub fn process_knowledge_events(
|
||||
match event.event_type {
|
||||
KnowledgeEventType::DirectObservation { target, position } => {
|
||||
if let Some(stable_id) = registry.to_stable(target) {
|
||||
observer_kg.observe_entity(stable_id, position, event.tick);
|
||||
if let Some(claim) =
|
||||
observer_kg.observe_entity(stable_id, position, event.tick)
|
||||
{
|
||||
// StableId is Copy — capture before moving claim into event.
|
||||
let told_by = claim.told_by;
|
||||
|
||||
// Relationship shift (D-083): NPC who provided false info
|
||||
// becomes PersonOfInterest in the observer's knowledge graph.
|
||||
// Upsert: create a minimal entry if the source isn't yet known.
|
||||
observer_kg
|
||||
.entities
|
||||
.entry(told_by)
|
||||
.and_modify(|e| e.relationship = RelationshipState::PersonOfInterest)
|
||||
.or_insert_with(|| EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: event.tick,
|
||||
confidence: KnowledgeConfidence::Suspects,
|
||||
source: KnowledgeSource::Inferred { basis: vec![] },
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::PersonOfInterest,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
});
|
||||
|
||||
// Pre-resolve display names for the monologue consumer.
|
||||
let source_entity = registry.to_entity(&told_by);
|
||||
let source_display_name = source_entity
|
||||
.and_then(|e| npc_names.get(e).ok())
|
||||
.map(|n| n.0.clone())
|
||||
.unwrap_or_else(|| format!("#{}", told_by.0));
|
||||
let subject_display_name = npc_names
|
||||
.get(target)
|
||||
.ok()
|
||||
.map(|n| n.0.clone())
|
||||
.unwrap_or_else(|| format!("#{}", stable_id.0));
|
||||
|
||||
tracing::info!(
|
||||
observer = ?event.observer,
|
||||
target = stable_id.0,
|
||||
told_by = told_by.0,
|
||||
source = source_display_name,
|
||||
subject = subject_display_name,
|
||||
"Contradiction detected: ToldBy position differs from direct observation (D-083)"
|
||||
);
|
||||
contradiction_queue.push(ContradictionDetectedEvent {
|
||||
observer: event.observer,
|
||||
target: stable_id,
|
||||
claim,
|
||||
source_display_name,
|
||||
subject_display_name,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
debug_assert!(
|
||||
false,
|
||||
@@ -135,6 +288,68 @@ pub fn process_knowledge_events(
|
||||
);
|
||||
}
|
||||
}
|
||||
KnowledgeEventType::KnowledgeGranted { grant, source } => {
|
||||
match grant {
|
||||
ProcessedKnowledgeGrant::Fact(fg) => {
|
||||
let should_insert = observer_kg
|
||||
.facts
|
||||
.get(&fg.fact_id)
|
||||
.map(|existing| fg.confidence > existing.confidence)
|
||||
.unwrap_or(true);
|
||||
if should_insert {
|
||||
observer_kg.facts.insert(
|
||||
fg.fact_id.clone(),
|
||||
FactKnowledge {
|
||||
confidence: fg.confidence,
|
||||
source,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: event.tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
"KnowledgeGranted(Fact): {:?} at confidence {:?}, tick {}",
|
||||
fg.fact_id,
|
||||
fg.confidence,
|
||||
event.tick,
|
||||
);
|
||||
}
|
||||
}
|
||||
ProcessedKnowledgeGrant::Entity(eg) => {
|
||||
// Insert or upgrade entity knowledge entry.
|
||||
// Always use ToldBy source — entity grants come from NPC testimony.
|
||||
let entry = observer_kg.entities.entry(eg.target_id).or_insert_with(|| {
|
||||
EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: event.tick,
|
||||
confidence: eg.confidence,
|
||||
source: source.clone(),
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
}
|
||||
});
|
||||
// Upgrade confidence and source if new grant is higher.
|
||||
if eg.confidence > entry.confidence {
|
||||
entry.confidence = eg.confidence;
|
||||
entry.source = source;
|
||||
entry.last_updated_tick = event.tick;
|
||||
}
|
||||
// Merge attributes (grant may supply partial attribute set).
|
||||
for (k, v) in eg.attributes {
|
||||
entry.known_attributes.insert(k, v);
|
||||
}
|
||||
tracing::debug!(
|
||||
"KnowledgeGranted(Entity): StableId {:?} at confidence {:?}, tick {}",
|
||||
eg.target_id,
|
||||
eg.confidence,
|
||||
event.tick,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +411,7 @@ mod tests {
|
||||
let _ = observer_sid; // registered for completeness
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
@@ -236,6 +452,7 @@ mod tests {
|
||||
registry.register(observer);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
@@ -261,6 +478,7 @@ mod tests {
|
||||
let mut world = World::new();
|
||||
let registry = EntityRegistry::new(0);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
|
||||
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
|
||||
let fake_target = world.spawn_empty().id();
|
||||
@@ -339,4 +557,175 @@ mod tests {
|
||||
"decay should run on tick 10 and downgrade confidence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_direct_observation_detects_contradiction() {
|
||||
let mut world = World::new();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let target_ecs = world.spawn_empty().id();
|
||||
let target_sid = registry.register(target_ecs);
|
||||
let informant_sid = StableId(999);
|
||||
|
||||
// Observer has ToldBy knowledge: target at (10, 10) at tick 100
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.entities.insert(
|
||||
target_sid,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(TilePosition::new(10, 10, 0)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant_sid,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let observer = world.spawn(kg).id();
|
||||
registry.register(observer);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
|
||||
// Push DirectObservation at DIFFERENT position, within window
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
observer,
|
||||
tick: 200,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: target_ecs,
|
||||
position: TilePosition::new(15, 10, 0),
|
||||
},
|
||||
});
|
||||
world.insert_resource(queue);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_knowledge_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Verify: KG entry is Contradicted
|
||||
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
||||
let entry = kg.entity_knowledge(&target_sid).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Contradicted);
|
||||
assert!(entry.contradicted_claim.is_some());
|
||||
let claim = entry.contradicted_claim.as_ref().unwrap();
|
||||
assert_eq!(claim.told_by, informant_sid);
|
||||
assert_eq!(claim.claimed_position, TilePosition::new(10, 10, 0));
|
||||
assert_eq!(claim.observed_position, TilePosition::new(15, 10, 0));
|
||||
|
||||
// Verify: ContradictionDetectedQueue has the event
|
||||
let cq = world.resource::<ContradictionDetectedQueue>();
|
||||
assert_eq!(cq.events.len(), 1);
|
||||
assert_eq!(cq.events[0].target, target_sid);
|
||||
assert_eq!(cq.events[0].claim.told_by, informant_sid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_event_when_position_matches() {
|
||||
// DirectObservation at the SAME position as ToldBy:
|
||||
// ContradictionDetectedQueue must stay empty.
|
||||
let mut world = World::new();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let target_ecs = world.spawn_empty().id();
|
||||
let target_sid = registry.register(target_ecs);
|
||||
let informant_sid = StableId(77);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.entities.insert(
|
||||
target_sid,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(TilePosition::new(10, 10, 0)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant_sid,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let observer = world.spawn(kg).id();
|
||||
registry.register(observer);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
|
||||
// DirectObservation at the SAME position
|
||||
let mut queue = KnowledgeEventQueue::default();
|
||||
queue.push(KnowledgeEvent {
|
||||
observer,
|
||||
tick: 200,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: target_ecs,
|
||||
position: TilePosition::new(10, 10, 0), // same position
|
||||
},
|
||||
});
|
||||
world.insert_resource(queue);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_knowledge_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let cq = world.resource::<ContradictionDetectedQueue>();
|
||||
assert!(
|
||||
cq.is_empty(),
|
||||
"Matching position should not produce a contradiction event"
|
||||
);
|
||||
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
|
||||
let entry = kg.entity_knowledge(&target_sid).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_detected_queue_drains_correctly() {
|
||||
// ContradictionDetectedQueue.drain() should empty the queue
|
||||
// and return all accumulated events.
|
||||
let mut queue = ContradictionDetectedQueue::default();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
let mut world = World::new();
|
||||
let e = world.spawn_empty().id();
|
||||
|
||||
queue.push(ContradictionDetectedEvent {
|
||||
observer: e,
|
||||
target: StableId(1),
|
||||
claim: ContradictionClaim {
|
||||
told_by: StableId(99),
|
||||
told_tick: 50,
|
||||
claimed_position: TilePosition::new(1, 1, 0),
|
||||
observed_position: TilePosition::new(5, 5, 0),
|
||||
detected_tick: 100,
|
||||
},
|
||||
source_display_name: "Sera".to_string(),
|
||||
subject_display_name: "Kael".to_string(),
|
||||
});
|
||||
queue.push(ContradictionDetectedEvent {
|
||||
observer: e,
|
||||
target: StableId(2),
|
||||
claim: ContradictionClaim {
|
||||
told_by: StableId(88),
|
||||
told_tick: 60,
|
||||
claimed_position: TilePosition::new(2, 2, 0),
|
||||
observed_position: TilePosition::new(6, 6, 0),
|
||||
detected_tick: 100,
|
||||
},
|
||||
source_display_name: "NPC_88".to_string(),
|
||||
subject_display_name: "NPC_2".to_string(),
|
||||
});
|
||||
|
||||
assert!(!queue.is_empty());
|
||||
let drained = queue.drain();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(queue.is_empty(), "Queue should be empty after drain");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,46 @@ impl KnowledgeGraph {
|
||||
// --- Write Operations ---
|
||||
|
||||
/// Record a direct observation of another entity (entity is in LOS).
|
||||
pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) {
|
||||
///
|
||||
/// Returns `Some(ContradictionClaim)` if a position contradiction was
|
||||
/// detected against a recent `ToldBy` source (D-083). The caller should
|
||||
/// push a `ContradictionDetected` event when this returns `Some`.
|
||||
pub fn observe_entity(
|
||||
&mut self,
|
||||
target: StableId,
|
||||
position: TilePosition,
|
||||
tick: u64,
|
||||
) -> Option<ContradictionClaim> {
|
||||
// --- Pre-overwrite contradiction check (D-083) ---
|
||||
//
|
||||
// If the existing entry has a ToldBy source with a different position,
|
||||
// and the told-tick is within CONTRADICTION_WINDOW_TICKS of now,
|
||||
// this is a contradiction: someone lied or was wrong about where
|
||||
// this entity would be.
|
||||
let contradiction = self.entities.get(&target).and_then(|existing| {
|
||||
if let KnowledgeSource::ToldBy {
|
||||
source_id,
|
||||
tick: told_tick,
|
||||
} = &existing.source
|
||||
{
|
||||
let age = tick.saturating_sub(*told_tick);
|
||||
let claimed_pos = existing.last_known_position?;
|
||||
if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position {
|
||||
Some(ContradictionClaim {
|
||||
told_by: *source_id,
|
||||
told_tick: *told_tick,
|
||||
claimed_position: claimed_pos,
|
||||
observed_position: position,
|
||||
detected_tick: tick,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let entry = self
|
||||
.entities
|
||||
.entry(target)
|
||||
@@ -121,18 +160,31 @@ impl KnowledgeGraph {
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
});
|
||||
entry.last_known_position = Some(position);
|
||||
entry.last_observed_tick = tick;
|
||||
entry.last_updated_tick = tick;
|
||||
entry.confidence = KnowledgeConfidence::Direct;
|
||||
entry.source = KnowledgeSource::DirectObservation { tick };
|
||||
// Stale entries become Active again on fresh observation.
|
||||
// Contradicted entries stay Contradicted even if you're looking
|
||||
// at the entity right now — the contradiction is still unresolved.
|
||||
if entry.state == KnowledgeState::Stale {
|
||||
|
||||
if contradiction.is_some() {
|
||||
entry.state = KnowledgeState::Contradicted;
|
||||
entry.contradicted_claim = contradiction.clone();
|
||||
} else if entry.state == KnowledgeState::Stale {
|
||||
// Stale entries become Active again on fresh observation.
|
||||
entry.state = KnowledgeState::Active;
|
||||
}
|
||||
// Contradicted entries without a new contradiction stay Contradicted —
|
||||
// the previous contradiction is still unresolved.
|
||||
//
|
||||
// After this write, entry.source is DirectObservation, so subsequent
|
||||
// observations will NOT re-trigger contradiction detection (the
|
||||
// pre-overwrite check only fires when existing.source is ToldBy).
|
||||
// This is intentional: once contradicted, the entry reflects the
|
||||
// observer's own eyes and cannot be "contradicted" again by looking.
|
||||
|
||||
contradiction
|
||||
}
|
||||
|
||||
/// Entity has left the observer's LOS. Downgrade from Direct.
|
||||
@@ -164,10 +216,18 @@ impl KnowledgeGraph {
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 0,
|
||||
confidence: KnowledgeConfidence::Suspects,
|
||||
source: KnowledgeSource::DirectObservation { tick },
|
||||
// Heard/Close — not DirectObservation, because walk-away is
|
||||
// not a confirmed sighting. Using DirectObservation here
|
||||
// would falsely inoculate the entry against contradiction
|
||||
// detection (pre-overwrite check only fires on ToldBy source).
|
||||
source: KnowledgeSource::Heard {
|
||||
tick,
|
||||
range: super::types::SoundRange::Close,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
});
|
||||
|
||||
let type_str = match interaction_type {
|
||||
@@ -387,6 +447,7 @@ mod tests {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
};
|
||||
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
|
||||
|
||||
@@ -513,6 +574,7 @@ mod tests {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -522,6 +584,7 @@ mod tests {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -626,6 +689,7 @@ mod tests {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -648,6 +712,7 @@ mod tests {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -746,4 +811,362 @@ mod tests {
|
||||
"FactionOnly must pass when observer knows the matching faction_id"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Contradiction detection tests (D-083, #547) ---
|
||||
|
||||
#[test]
|
||||
fn contradiction_detected_when_told_by_position_differs() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(99);
|
||||
|
||||
// Someone told us the target is at (10, 10) at tick 100
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(10, 10)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Direct observation at (15, 10) at tick 200 — within window (600 ticks)
|
||||
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||
|
||||
// Contradiction should be detected
|
||||
assert!(result.is_some(), "Should detect contradiction");
|
||||
let claim = result.unwrap();
|
||||
assert_eq!(claim.told_by, informant);
|
||||
assert_eq!(claim.told_tick, 100);
|
||||
assert_eq!(claim.claimed_position, make_position(10, 10));
|
||||
assert_eq!(claim.observed_position, make_position(15, 10));
|
||||
assert_eq!(claim.detected_tick, 200);
|
||||
|
||||
// Entry should be Contradicted
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Contradicted);
|
||||
assert!(entry.contradicted_claim.is_some());
|
||||
// But confidence is upgraded to Direct (we're looking at them)
|
||||
assert_eq!(entry.confidence, KnowledgeConfidence::Direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_when_told_by_position_matches() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(99);
|
||||
|
||||
// Told target is at (10, 10)
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(10, 10)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Observe at SAME position — no contradiction
|
||||
let result = g.observe_entity(target, make_position(10, 10), 200);
|
||||
assert!(result.is_none(), "Same position should not be a contradiction");
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Active);
|
||||
assert!(entry.contradicted_claim.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_outside_time_window() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(99);
|
||||
|
||||
// Told at tick 100, position (10, 10)
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(10, 10)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Observe at different position BUT outside window (100 + 601 = 701)
|
||||
let result = g.observe_entity(target, make_position(15, 10), 701);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_at_exact_window_boundary() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(99);
|
||||
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(10, 10)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS)
|
||||
let result = g.observe_entity(target, make_position(15, 10), 700);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"Exactly at window boundary (age == 600) should still detect contradiction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_for_direct_observation_source() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
|
||||
// Previous knowledge from DirectObservation (not ToldBy)
|
||||
g.observe_entity(target, make_position(10, 10), 100);
|
||||
|
||||
// New observation at different position — NOT a contradiction
|
||||
// (we just moved, or they moved; no one lied)
|
||||
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"DirectObservation source should never trigger contradiction"
|
||||
);
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
assert_eq!(entry.state, KnowledgeState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_for_background_source() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
|
||||
// Background knowledge with a position
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(10, 10)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 0,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let result = g.observe_entity(target, make_position(15, 10), 100);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Background source should not trigger contradiction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_when_told_by_has_no_position() {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(99);
|
||||
|
||||
// ToldBy but no position was claimed
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: None, // no position claimed
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let result = g.observe_entity(target, make_position(15, 10), 200);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"ToldBy without position should not trigger contradiction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradicted_claim_entry_field_matches_returned_claim() {
|
||||
// Verify that entry.contradicted_claim is populated with identical
|
||||
// data to the ContradictionClaim returned by observe_entity (D-083).
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(1);
|
||||
let informant = StableId(42);
|
||||
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(5, 5)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 50,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 50,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let returned = g
|
||||
.observe_entity(target, make_position(12, 5), 300)
|
||||
.expect("contradiction should be detected");
|
||||
|
||||
let entry = g.entity_knowledge(&target).unwrap();
|
||||
let stored = entry.contradicted_claim.as_ref().expect("field should be populated");
|
||||
|
||||
assert_eq!(stored.told_by, returned.told_by);
|
||||
assert_eq!(stored.told_tick, returned.told_tick);
|
||||
assert_eq!(stored.claimed_position, returned.claimed_position);
|
||||
assert_eq!(stored.observed_position, returned.observed_position);
|
||||
assert_eq!(stored.detected_tick, returned.detected_tick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_observation_keeps_contradicted_state_when_no_new_told_by() {
|
||||
// After a contradiction is detected, subsequent DirectObservation
|
||||
// does NOT clear the Contradicted state (D-083: "unresolved").
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let target = StableId(7);
|
||||
let informant = StableId(8);
|
||||
|
||||
// Set up ToldBy knowledge
|
||||
g.entities.insert(
|
||||
target,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(3, 3)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 10,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 10,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// First observation: contradiction detected
|
||||
let claim = g.observe_entity(target, make_position(9, 3), 200);
|
||||
assert!(claim.is_some(), "contradiction should fire");
|
||||
assert_eq!(
|
||||
g.entity_knowledge(&target).unwrap().state,
|
||||
KnowledgeState::Contradicted
|
||||
);
|
||||
|
||||
// Second observation (now source is DirectObservation, different position):
|
||||
// state must stay Contradicted — contradiction is still unresolved.
|
||||
let claim2 = g.observe_entity(target, make_position(11, 3), 300);
|
||||
assert!(claim2.is_none(), "no new contradiction: DirectObservation source");
|
||||
assert_eq!(
|
||||
g.entity_knowledge(&target).unwrap().state,
|
||||
KnowledgeState::Contradicted,
|
||||
"Contradicted state must persist until explicitly resolved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_entities_only_told_by_one_contradicts() {
|
||||
// Edge case: observer knows two entities.
|
||||
// Entity A has ToldBy source, Entity B has DirectObservation.
|
||||
// Only Entity A should produce a contradiction.
|
||||
let mut g = KnowledgeGraph::new();
|
||||
let entity_a = StableId(10);
|
||||
let entity_b = StableId(20);
|
||||
let informant = StableId(99);
|
||||
|
||||
// Entity A: ToldBy with position
|
||||
g.entities.insert(
|
||||
entity_a,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(make_position(1, 1)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: informant,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Entity B: DirectObservation (no informant to lie)
|
||||
g.observe_entity(entity_b, make_position(5, 5), 100);
|
||||
|
||||
// Observe both at different positions at tick 200
|
||||
let a_result = g.observe_entity(entity_a, make_position(8, 1), 200);
|
||||
let b_result = g.observe_entity(entity_b, make_position(9, 5), 200);
|
||||
|
||||
assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict");
|
||||
assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict");
|
||||
assert_eq!(
|
||||
g.entity_knowledge(&entity_a).unwrap().state,
|
||||
KnowledgeState::Contradicted
|
||||
);
|
||||
assert_eq!(
|
||||
g.entity_knowledge(&entity_b).unwrap().state,
|
||||
KnowledgeState::Active
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
pub mod content_registry;
|
||||
pub mod events;
|
||||
pub mod graph;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
pub use content_registry::ContentEntityRegistry;
|
||||
pub use events::{
|
||||
ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent,
|
||||
KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant,
|
||||
ProcessedKnowledgeGrant,
|
||||
};
|
||||
pub use graph::KnowledgeGraph;
|
||||
pub use registry::{EntityRegistry, StableEntityId};
|
||||
pub use types::*;
|
||||
@@ -24,11 +30,17 @@ pub struct KnowledgePlugin;
|
||||
impl Plugin for KnowledgePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<KnowledgeEventQueue>()
|
||||
.init_resource::<ContradictionDetectedQueue>()
|
||||
.init_resource::<EntityRegistry>()
|
||||
.init_resource::<ContentEntityRegistry>()
|
||||
.init_resource::<DecayThresholds>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Runs after snapshot: contradiction monologue and relationship
|
||||
// shifts from KnowledgeGranted events lag by one tick (~0.1s).
|
||||
// Acceptable — the player perceives the contradiction on the
|
||||
// next snapshot, which reads as a natural reaction delay.
|
||||
events::process_knowledge_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
events::decay_knowledge.after(events::process_knowledge_events),
|
||||
|
||||
@@ -54,6 +54,27 @@ const _: () = {
|
||||
assert!(KnowledgeConfidence::Direct as u8 == 3);
|
||||
};
|
||||
|
||||
impl TryFrom<&str> for KnowledgeConfidence {
|
||||
type Error = String;
|
||||
|
||||
/// Parse a confidence string from YAML content into the typed enum.
|
||||
///
|
||||
/// Case-insensitive. Accepts both camelCase and underscore/hyphen variants.
|
||||
/// Used by KnowledgeGrant processing (D-079).
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"suspects" => Ok(Self::Suspects),
|
||||
"knowsof" | "knows_of" | "knows-of" => Ok(Self::KnowsOf),
|
||||
"knowsdetails" | "knows_details" | "knows-details" => Ok(Self::KnowsDetails),
|
||||
"direct" => Ok(Self::Direct),
|
||||
other => Err(format!(
|
||||
"unknown confidence level '{}': expected one of suspects, knowsof, knowsdetails, direct",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KnowledgeConfidence {
|
||||
/// Step down one confidence level (used by decay system).
|
||||
pub fn decayed(self) -> Self {
|
||||
@@ -146,6 +167,33 @@ impl RelationshipState {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contradiction Detection (D-083) ---
|
||||
|
||||
/// Ticks within which a position discrepancy counts as a contradiction.
|
||||
/// 600 ticks = 1 game-hour (at 10 tps per D-031).
|
||||
/// Outside this window, stale ToldBy information is simply overwritten.
|
||||
pub const CONTRADICTION_WINDOW_TICKS: u64 = 600;
|
||||
|
||||
/// Records details of a detected contradiction on an EntityKnowledge entry.
|
||||
///
|
||||
/// Populated when `observe_entity()` finds a position discrepancy with a
|
||||
/// recent `ToldBy` source. Both the ToldBy entry and the DirectObservation
|
||||
/// receive `Contradicted` state (epistemic neutrality — the engine does
|
||||
/// not determine which is wrong). D-083, Q-026 resolution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContradictionClaim {
|
||||
/// Who told us the (now-contradicted) information.
|
||||
pub told_by: StableId,
|
||||
/// Tick when the ToldBy information was received.
|
||||
pub told_tick: u64,
|
||||
/// Position the source claimed the entity was at.
|
||||
pub claimed_position: TilePosition,
|
||||
/// Position we directly observed the entity at.
|
||||
pub observed_position: TilePosition,
|
||||
/// Tick when the contradiction was detected.
|
||||
pub detected_tick: u64,
|
||||
}
|
||||
|
||||
// --- Entity Knowledge ---
|
||||
|
||||
/// What entity A knows about entity B.
|
||||
@@ -169,6 +217,10 @@ pub struct EntityKnowledge {
|
||||
/// Known attributes of the target entity.
|
||||
/// Keys are structured (name, role, faction, etc.)
|
||||
pub known_attributes: BTreeMap<String, String>,
|
||||
/// Populated when a contradiction is detected between ToldBy and
|
||||
/// DirectObservation sources (D-083). None when no contradiction exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contradicted_claim: Option<ContradictionClaim>,
|
||||
}
|
||||
|
||||
/// Non-entity fact knowledge (locations, events, abstract knowledge).
|
||||
@@ -183,6 +235,10 @@ pub struct FactKnowledge {
|
||||
pub state: KnowledgeState,
|
||||
/// Tick when this fact was learned.
|
||||
pub acquired_tick: u64,
|
||||
/// When true, this fact must not be transferred to other entities via NPC-to-NPC propagation.
|
||||
/// D-080: models secrets whose sharing is existentially dangerous regardless of trust tier.
|
||||
#[serde(default)]
|
||||
pub disclosure_blocked: bool,
|
||||
}
|
||||
|
||||
// --- Decay Configuration ---
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
//! Unprompted disclosure system (D-081).
|
||||
//!
|
||||
//! Two-system pipeline:
|
||||
//! - `derive_disclosure_candidates`: per-NPC KG filter, recomputed every 30 ticks
|
||||
//! - `process_unprompted_disclosure`: trigger gates, StableId-ordered firing
|
||||
//!
|
||||
//! NPCs check only their own KG (D-010 principle 2 — no cross-entity KG reads).
|
||||
//! Disclosure grants the fact to the player's KG via `KnowledgeGranted` event
|
||||
//! and emits a placeholder `MonologueEvent` (Layer 4 line selection in #172).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::knowledge::events::{
|
||||
KnowledgeEvent, KnowledgeEventType, ProcessedFactGrant, ProcessedKnowledgeGrant,
|
||||
};
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, KnowledgeState, RelationshipState, StableId};
|
||||
use crate::knowledge::{KnowledgeEventQueue, KnowledgeGraph, StableEntityId};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::trait_modifiers::{traits_to_keys, TraitModifierConfig};
|
||||
use crate::npc::{Contentment, Npc, PersonalityTraits};
|
||||
use crate::simulation::monologue::MonologueBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Recompute `DisclosureCandidates` every N ticks (30 ticks = 3 game-minutes at 10 tps).
|
||||
const CANDIDATE_REFRESH_TICKS: u64 = 30;
|
||||
|
||||
/// Per-NPC cooldown ticks after a disclosure fires (300 = 30 game-minutes).
|
||||
const NPC_COOLDOWN_TICKS: u64 = 300;
|
||||
|
||||
/// Minimum ticks between any two disclosures (global rate limit).
|
||||
const GLOBAL_RATE_LIMIT_TICKS: u64 = 10;
|
||||
|
||||
/// Max candidates retained in `DisclosureCandidates`.
|
||||
const MAX_CANDIDATES: usize = 10;
|
||||
|
||||
/// Witness inhibition check radius (Manhattan distance, tiles).
|
||||
const WITNESS_RADIUS: u32 = 5;
|
||||
|
||||
/// Minimum NPC→player trust for Surface-tier disclosure.
|
||||
const SURFACE_TRUST: i8 = 0;
|
||||
|
||||
/// NPC→player trust at which witness inhibition is waived (Secret tier, D-081).
|
||||
const SECRET_TRUST: i8 = 7;
|
||||
|
||||
/// Trust level below which a nearby NPC counts as an untrusted witness.
|
||||
const REAL_TRUST: i8 = 3;
|
||||
|
||||
/// Player proximity range for candidate recompute (tiles). Matches voice range.
|
||||
const PLAYER_RANGE_TILES: u32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-NPC computed disclosure candidate pool (D-081).
|
||||
///
|
||||
/// Recomputed every `CANDIDATE_REFRESH_TICKS` ticks by `derive_disclosure_candidates`.
|
||||
/// Consumed by `process_unprompted_disclosure` when all trigger gates pass.
|
||||
///
|
||||
/// Facts are sorted: confidence desc, then `acquired_tick` desc. Capped at
|
||||
/// `MAX_CANDIDATES`.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct DisclosureCandidates {
|
||||
/// Fact IDs eligible for disclosure, ordered by priority.
|
||||
pub candidates: Vec<FactId>,
|
||||
/// Tick when this pool was last computed. 0 = never computed.
|
||||
pub computed_tick: u64,
|
||||
}
|
||||
|
||||
/// Per-NPC disclosure cooldown state (D-081).
|
||||
///
|
||||
/// Tracks which facts have been disclosed during the current window
|
||||
/// (primary narrative quality gate) and the per-NPC silence period.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct DisclosureCooldown {
|
||||
/// Facts disclosed this window — prevents repeating the same fact.
|
||||
pub per_fact_history: BTreeSet<FactId>,
|
||||
/// Tick when the per-NPC cooldown expires. 0 = no active cooldown.
|
||||
pub npc_cooldown_until: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Global rate limiter: at most 1 unprompted disclosure per `GLOBAL_RATE_LIMIT_TICKS`.
|
||||
///
|
||||
/// When multiple NPCs are eligible simultaneously, the one with the lowest
|
||||
/// StableId fires first (deterministic, D-010 principle 4).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct DisclosureGlobalRateLimit {
|
||||
pub last_disclosure_tick: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// derive_disclosure_candidates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Recompute `DisclosureCandidates` for each Active NPC.
|
||||
///
|
||||
/// Runs every `CANDIDATE_REFRESH_TICKS` ticks (checked per-NPC via `computed_tick`).
|
||||
/// Filters the NPC's own KG through:
|
||||
///
|
||||
/// - `KnowledgeState::Active` only
|
||||
/// - Confidence >= `KnowsOf` (or trait-lowered threshold via `TraitModifierConfig`)
|
||||
/// - Not in `per_fact_history`
|
||||
/// - `disclosure_blocked != true`
|
||||
/// - ToldBy exclusion if Cautious trait is configured
|
||||
///
|
||||
/// Applies Stage 1 trait filters from `TraitModifierConfig`. Sorted by
|
||||
/// confidence desc then `acquired_tick` desc. Capped at `MAX_CANDIDATES`.
|
||||
pub fn derive_disclosure_candidates(
|
||||
time: Res<SimulationTime>,
|
||||
trait_config: Res<TraitModifierConfig>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
&KnowledgeGraph,
|
||||
&DisclosureCooldown,
|
||||
Option<&PersonalityTraits>,
|
||||
&mut DisclosureCandidates,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
|
||||
for (kg, cooldown, traits_opt, mut candidates) in &mut npc_query {
|
||||
// Only recompute when the refresh interval has elapsed.
|
||||
if candidates.computed_tick != 0
|
||||
&& current_tick.saturating_sub(candidates.computed_tick) < CANDIDATE_REFRESH_TICKS
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let trait_keys = traits_opt
|
||||
.map(|t| traits_to_keys(&t.traits))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Effective confidence floor.
|
||||
// `lowest_min_confidence` returns the most permissive threshold across
|
||||
// all traits (additive expansion — e.g., Gossipy sets Suspects,
|
||||
// which wins over Cautious raising to KnowsDetails).
|
||||
let min_confidence = trait_config
|
||||
.lowest_min_confidence(&trait_keys)
|
||||
.unwrap_or(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Cautious trait: exclude facts with ToldBy (rumour) source.
|
||||
let exclude_told_by = trait_keys.iter().any(|k| {
|
||||
trait_config
|
||||
.modifier_for(k)
|
||||
.is_some_and(|m| m.stage1.exclude_told_by)
|
||||
});
|
||||
|
||||
// Loyal trait: exclude facts sourced from high-trust entities.
|
||||
// "Don't gossip about your friends" — if ToldBy source has Friendly
|
||||
// relationship in this NPC's KG, suppress the fact. D-081.
|
||||
let exclude_high_trust = trait_config.any_excludes_high_trust(&trait_keys);
|
||||
|
||||
let mut pool: Vec<(FactId, u64, KnowledgeConfidence)> = kg
|
||||
.known_facts_iter()
|
||||
.filter(|(fact_id, fact)| {
|
||||
// Active state only.
|
||||
if fact.state != KnowledgeState::Active {
|
||||
return false;
|
||||
}
|
||||
// Not already disclosed this window.
|
||||
if cooldown.per_fact_history.contains(*fact_id) {
|
||||
return false;
|
||||
}
|
||||
// Existentially dangerous secrets never disclosed (D-080).
|
||||
if fact.disclosure_blocked {
|
||||
return false;
|
||||
}
|
||||
// Confidence threshold.
|
||||
if fact.confidence < min_confidence {
|
||||
return false;
|
||||
}
|
||||
// Cautious: skip ToldBy-source facts.
|
||||
if exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
|
||||
return false;
|
||||
}
|
||||
// Loyal: skip facts from high-trust (Friendly) source entities.
|
||||
if exclude_high_trust {
|
||||
if let KnowledgeSource::ToldBy { source_id, .. } = &fact.source {
|
||||
if kg.relationship_with(source_id) == RelationshipState::Friendly {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|(id, fact)| (id.clone(), fact.acquired_tick, fact.confidence))
|
||||
.collect();
|
||||
|
||||
// Sort: confidence desc, then acquired_tick desc (most recent first).
|
||||
pool.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
pool.truncate(MAX_CANDIDATES);
|
||||
|
||||
candidates.candidates = pool.into_iter().map(|(id, _, _)| id).collect();
|
||||
candidates.computed_tick = current_tick;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process_unprompted_disclosure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collected during the read pass; used for sorting and winner selection.
|
||||
struct EligibleNpc {
|
||||
entity: Entity,
|
||||
stable_id: StableId,
|
||||
pos: TilePosition,
|
||||
fact_id: FactId,
|
||||
override_witness: bool,
|
||||
}
|
||||
|
||||
/// Fire one unprompted disclosure per tick window when all trigger gates pass (D-081).
|
||||
///
|
||||
/// Trigger gates (all must pass for a given NPC):
|
||||
///
|
||||
/// 1. NPC has a `StableEntityId` (required for trust lookup)
|
||||
/// 2. `DisclosureCandidates` pool is non-empty
|
||||
/// 3. NPC→player trust >= `SURFACE_TRUST`
|
||||
/// 4. `MoodState` != `NpcMood::Hostile`
|
||||
/// 5. `Contentment.level` >= −10
|
||||
/// 6. Per-NPC cooldown not active
|
||||
/// 7. Player within `PLAYER_RANGE_TILES`
|
||||
/// 8. Witness inhibition: no untrusted NPCs within `WITNESS_RADIUS` tiles
|
||||
/// (waived if NPC→player trust >= `SECRET_TRUST` or Talkative trait)
|
||||
/// 9. Location privacy: stubbed as always-pass — full impl in #172
|
||||
///
|
||||
/// Multiple eligible NPCs sorted by ascending StableId (D-010 principle 4).
|
||||
/// First in order that also passes witness inhibition fires.
|
||||
/// Subject to global rate limit (`GLOBAL_RATE_LIMIT_TICKS`).
|
||||
pub fn process_unprompted_disclosure(
|
||||
time: Res<SimulationTime>,
|
||||
spatial: Res<NaiveSpatialIndex>,
|
||||
relationship_graph: Res<RelationshipGraph>,
|
||||
trait_config: Res<TraitModifierConfig>,
|
||||
mut rate_limit: ResMut<DisclosureGlobalRateLimit>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
player_pos_query: Query<
|
||||
(Entity, &TilePosition, Option<&StableEntityId>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
mut player_mono_query: Query<&mut MonologueBuffer, With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&StableEntityId>,
|
||||
Option<&MoodState>,
|
||||
Option<&Contentment>,
|
||||
Option<&PersonalityTraits>,
|
||||
&DisclosureCandidates,
|
||||
&mut DisclosureCooldown,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
witness_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
|
||||
// Gate: global rate limit — at most 1 disclosure per GLOBAL_RATE_LIMIT_TICKS.
|
||||
if current_tick.saturating_sub(rate_limit.last_disclosure_tick) < GLOBAL_RATE_LIMIT_TICKS {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect player state. Single-player assumption (D-010).
|
||||
let Ok((player_entity, player_pos_ref, player_sid_opt)) = player_pos_query.single() else {
|
||||
return;
|
||||
};
|
||||
let player_pos = *player_pos_ref;
|
||||
let player_sid: Option<StableId> = player_sid_opt.map(|s| s.0);
|
||||
|
||||
// --- Read pass: collect all NPCs passing the non-spatial gates ---
|
||||
let mut eligible: Vec<EligibleNpc> = npc_query
|
||||
.iter()
|
||||
.filter_map(
|
||||
|(entity, pos, sid_opt, mood_opt, content_opt, traits_opt, candidates, cooldown)| {
|
||||
// Gate 1: must have a StableId for trust lookup.
|
||||
let npc_sid = sid_opt?.0;
|
||||
|
||||
// Gate 2: candidate pool non-empty.
|
||||
// Takes highest-priority candidate (sorted by confidence desc,
|
||||
// then recency desc in derive_disclosure_candidates). Full Layer 4
|
||||
// line selection with variety tracking is deferred to #172.
|
||||
let fact_id = candidates.candidates.first()?.clone();
|
||||
|
||||
// Gate 3: NPC→player trust >= Surface.
|
||||
let npc_player_trust = player_sid
|
||||
.and_then(|psid| relationship_graph.get_relationship(&npc_sid, &psid))
|
||||
.map(|e| e.trust)
|
||||
.unwrap_or(0);
|
||||
if npc_player_trust < SURFACE_TRUST {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 4: mood not Hostile.
|
||||
if mood_opt.is_some_and(|ms| ms.mood == NpcMood::Hostile) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 5: contentment >= -10.
|
||||
if content_opt.is_some_and(|c| c.level < -10) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 6: per-NPC cooldown not active.
|
||||
if current_tick < cooldown.npc_cooldown_until {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 7: player within PLAYER_RANGE_TILES (same z-level only).
|
||||
let in_range = pos
|
||||
.manhattan_distance(&player_pos)
|
||||
.is_some_and(|d| d <= PLAYER_RANGE_TILES);
|
||||
if !in_range {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Gate 9: location privacy — stubbed always-pass.
|
||||
// Full implementation deferred to #172 (Layer 4 disclosure pipeline).
|
||||
|
||||
// Compute witness inhibition override for gate 8.
|
||||
let trait_keys = traits_opt
|
||||
.map(|t| traits_to_keys(&t.traits))
|
||||
.unwrap_or_default();
|
||||
let override_witness = npc_player_trust >= SECRET_TRUST
|
||||
|| trait_config.any_overrides_witness_inhibition(&trait_keys);
|
||||
|
||||
Some(EligibleNpc {
|
||||
entity,
|
||||
stable_id: npc_sid,
|
||||
pos: *pos,
|
||||
fact_id,
|
||||
override_witness,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
if eligible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by ascending StableId for determinism (D-010 principle 4).
|
||||
eligible.sort_by_key(|c| c.stable_id);
|
||||
|
||||
// Gate 8: witness inhibition — find first NPC that passes spatial check.
|
||||
let winner = eligible.into_iter().find(|candidate| {
|
||||
if candidate.override_witness {
|
||||
return true;
|
||||
}
|
||||
!has_untrusted_witness(
|
||||
&candidate.pos,
|
||||
candidate.stable_id,
|
||||
player_entity,
|
||||
&spatial,
|
||||
&witness_sid_query,
|
||||
&relationship_graph,
|
||||
)
|
||||
});
|
||||
|
||||
let Some(winner) = winner else {
|
||||
return;
|
||||
};
|
||||
|
||||
// --- Fire the disclosure ---
|
||||
|
||||
// 1. Grant the fact to the player's KG via KnowledgeGranted event (ToldBy source).
|
||||
// Confidence capped at KnowsOf (same rule as NPC-to-NPC transfer, D-080).
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: current_tick,
|
||||
event_type: KnowledgeEventType::KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
|
||||
fact_id: winner.fact_id.clone(),
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
}),
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: winner.stable_id,
|
||||
tick: current_tick,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Placeholder monologue event — actual line selection deferred to #172
|
||||
// (Layer 4 unprompted disclosure pipeline reads DisclosureCandidates).
|
||||
if let Ok(mut mono_buf) = player_mono_query.single_mut() {
|
||||
mono_buf.set(MonologueEvent {
|
||||
id: format!("disclosure_{}", winner.fact_id.0),
|
||||
text: String::from("(Layer 4 line selection — ticket #172)"),
|
||||
duration_seconds: 4.0,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update NPC cooldown state.
|
||||
// Note: re-queries npc_query mutably after the read pass above. This is
|
||||
// safe because the read pass only borrows shared refs and completes before
|
||||
// this point. The two-phase pattern (read → select winner → write) avoids
|
||||
// holding a mutable borrow during iteration.
|
||||
if let Ok((_, _, _, _, _, _, _, mut cooldown)) = npc_query.get_mut(winner.entity) {
|
||||
cooldown.per_fact_history.insert(winner.fact_id.clone());
|
||||
cooldown.npc_cooldown_until = current_tick + NPC_COOLDOWN_TICKS;
|
||||
}
|
||||
|
||||
// 4. Advance global rate limit.
|
||||
rate_limit.last_disclosure_tick = current_tick;
|
||||
|
||||
tracing::debug!(
|
||||
tick = current_tick,
|
||||
npc_sid = ?winner.stable_id,
|
||||
fact_id = %winner.fact_id.0,
|
||||
"unprompted disclosure fired"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns true if any untrusted NPC is within `WITNESS_RADIUS` of `pos`.
|
||||
///
|
||||
/// "Untrusted" = the disclosing NPC's trust toward that witness is below
|
||||
/// `REAL_TRUST`. The player entity is excluded (they are the target).
|
||||
fn has_untrusted_witness(
|
||||
pos: &TilePosition,
|
||||
npc_sid: StableId,
|
||||
player_entity: Entity,
|
||||
spatial: &NaiveSpatialIndex,
|
||||
witness_sid_query: &Query<Option<&StableEntityId>, With<Npc>>,
|
||||
relationship_graph: &RelationshipGraph,
|
||||
) -> bool {
|
||||
for witness_entity in spatial.entities_in_range(pos, WITNESS_RADIUS) {
|
||||
if witness_entity == player_entity {
|
||||
continue;
|
||||
}
|
||||
if let Ok(Some(witness_sid)) = witness_sid_query.get(witness_entity) {
|
||||
let trust = relationship_graph
|
||||
.get_relationship(&npc_sid, &witness_sid.0)
|
||||
.map(|e| e.trust)
|
||||
.unwrap_or(0);
|
||||
if trust < REAL_TRUST {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{KnowledgeConfidence, KnowledgeSource, KnowledgeState};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
fn make_fact(
|
||||
confidence: KnowledgeConfidence,
|
||||
state: KnowledgeState,
|
||||
acquired_tick: u64,
|
||||
disclosure_blocked: bool,
|
||||
) -> crate::knowledge::types::FactKnowledge {
|
||||
crate::knowledge::types::FactKnowledge {
|
||||
confidence,
|
||||
source: KnowledgeSource::Background,
|
||||
state,
|
||||
acquired_tick,
|
||||
disclosure_blocked,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_kg(facts: Vec<(FactId, crate::knowledge::types::FactKnowledge)>) -> KnowledgeGraph {
|
||||
KnowledgeGraph::with_background(facts)
|
||||
}
|
||||
|
||||
/// Build a minimal App for derive_disclosure_candidates tests.
|
||||
fn build_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<SimulationTime>();
|
||||
app.init_resource::<TraitModifierConfig>();
|
||||
app.add_systems(Update, derive_disclosure_candidates);
|
||||
app
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// derive_disclosure_candidates tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn active_knowsof_fact_becomes_candidate() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("investigation.clue".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
candidates.candidates.contains(&fact_id),
|
||||
"Active KnowsOf fact should be in pool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclosure_blocked_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("secret.dangerous".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsDetails, KnowledgeState::Active, 5, true),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"disclosure_blocked fact must never be a candidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("cargo.manifest".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Stale, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Stale fact must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_disclosed_fact_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("dock.schedule".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let mut cooldown = DisclosureCooldown::default();
|
||||
cooldown.per_fact_history.insert(fact_id.clone());
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, cooldown, DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Fact in per_fact_history must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_capped_at_max() {
|
||||
let mut app = build_app();
|
||||
|
||||
// Spawn 15 facts — only MAX_CANDIDATES should survive.
|
||||
let facts: Vec<_> = (0..15)
|
||||
.map(|i| {
|
||||
(
|
||||
FactId(format!("fact.{:02}", i)),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, i as u64, false),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kg = make_kg(facts);
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert_eq!(
|
||||
candidates.candidates.len(),
|
||||
MAX_CANDIDATES,
|
||||
"Candidate pool must be capped at MAX_CANDIDATES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skipped_within_interval() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("investigation.clue".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
// Set computed_tick = 1 (non-zero). Tick 5 is within the 30-tick refresh window.
|
||||
let mut candidates = DisclosureCandidates::default();
|
||||
candidates.computed_tick = 1;
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), candidates))
|
||||
.id();
|
||||
|
||||
// Advance tick to 5 (within CANDIDATE_REFRESH_TICKS = 30).
|
||||
app.world_mut().resource_mut::<SimulationTime>().tick = 5;
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert_eq!(
|
||||
candidates.computed_tick, 1,
|
||||
"Refresh should be skipped within the interval"
|
||||
);
|
||||
assert!(
|
||||
candidates.candidates.is_empty(),
|
||||
"Candidates should remain empty (no recompute)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspects_confidence_below_default_floor_excluded() {
|
||||
let mut app = build_app();
|
||||
|
||||
let fact_id = FactId("rumour.vague".to_string());
|
||||
let kg = make_kg(vec![(
|
||||
fact_id.clone(),
|
||||
make_fact(KnowledgeConfidence::Suspects, KnowledgeState::Active, 5, false),
|
||||
)]);
|
||||
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default()))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let candidates = app.world().get::<DisclosureCandidates>(npc).unwrap();
|
||||
assert!(
|
||||
!candidates.candidates.contains(&fact_id),
|
||||
"Suspects-confidence fact must be below the KnowsOf default floor"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod disclosure;
|
||||
pub mod generate;
|
||||
pub mod interaction;
|
||||
pub mod mood;
|
||||
@@ -9,6 +10,7 @@ pub mod relationships;
|
||||
pub mod routine;
|
||||
pub mod tell_state;
|
||||
pub mod tolerance;
|
||||
pub mod trait_modifiers;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -31,6 +33,8 @@ impl Plugin for NpcPlugin {
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
.init_resource::<routine::RoutineDeviationEventQueue>()
|
||||
.init_resource::<disclosure::DisclosureGlobalRateLimit>()
|
||||
.init_resource::<trait_modifiers::TraitModifierConfig>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -61,6 +65,11 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::derive_disclosure_candidates
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::process_unprompted_disclosure
|
||||
.after(disclosure::derive_disclosure_candidates)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::RelationshipState;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::{
|
||||
Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold,
|
||||
@@ -77,6 +79,7 @@ fn derive_category(
|
||||
mood_state: &MoodState,
|
||||
relationships_opt: Option<&Relationships>,
|
||||
deviation_opt: Option<&RoutineDeviation>,
|
||||
kg_opt: Option<&KnowledgeGraph>,
|
||||
) -> Option<TellCategory> {
|
||||
// Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4)
|
||||
if deviation_opt.is_some() {
|
||||
@@ -102,10 +105,20 @@ fn derive_category(
|
||||
}
|
||||
|
||||
// Priority 5: Friendly — high contentment with at least one trusted relationship
|
||||
// D-082: prefer KG relationship state over ground-truth axis data.
|
||||
// Self-axis components (secret, stress, contentment, mood) remain ground-truth;
|
||||
// OTHER-entity relationship assessment uses the knowledge graph.
|
||||
if contentment.level > 20 {
|
||||
let has_positive_relationship = relationships_opt
|
||||
.map(|rels| rels.entries.iter().any(|r| r.trust_level > 3))
|
||||
.unwrap_or(false);
|
||||
let has_positive_relationship = if let Some(kg) = kg_opt {
|
||||
// D-082: use knowledge graph for other-entity relationship assessment
|
||||
kg.known_entities_iter()
|
||||
.any(|(_, ek)| ek.relationship == RelationshipState::Friendly)
|
||||
} else {
|
||||
// No KG: fall through to ground-truth axis data
|
||||
relationships_opt
|
||||
.map(|rels| rels.entries.iter().any(|r| r.trust_level > 3))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if has_positive_relationship {
|
||||
return Some(TellCategory::Friendly);
|
||||
}
|
||||
@@ -135,12 +148,13 @@ pub fn derive_tell_state(
|
||||
&MoodState,
|
||||
Option<&Relationships>,
|
||||
Option<&RoutineDeviation>,
|
||||
Option<&KnowledgeGraph>,
|
||||
&mut DerivedTellState,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in
|
||||
for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, kg_opt, mut tell) in
|
||||
npcs.iter_mut()
|
||||
{
|
||||
tell.category = derive_category(
|
||||
@@ -150,6 +164,7 @@ pub fn derive_tell_state(
|
||||
mood_state,
|
||||
relationships_opt,
|
||||
deviation_opt,
|
||||
kg_opt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +263,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
Some(&deviation()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::RoutineDeviation));
|
||||
}
|
||||
@@ -261,6 +277,7 @@ mod tests {
|
||||
&mood(NpcMood::Hostile),
|
||||
None,
|
||||
Some(&deviation()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::RoutineDeviation));
|
||||
}
|
||||
@@ -274,6 +291,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None, // No deviation
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::RoutineDeviation));
|
||||
}
|
||||
@@ -292,6 +310,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Nervous));
|
||||
}
|
||||
@@ -306,6 +325,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Guarded));
|
||||
}
|
||||
@@ -319,6 +339,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Nervous));
|
||||
}
|
||||
@@ -333,6 +354,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Still Guarded (Major secret, priority 4)
|
||||
assert_eq!(result, Some(TellCategory::Guarded));
|
||||
@@ -351,6 +373,7 @@ mod tests {
|
||||
&mood(NpcMood::Hostile),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Angry));
|
||||
}
|
||||
@@ -364,6 +387,7 @@ mod tests {
|
||||
&mood(NpcMood::Hostile),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Angry));
|
||||
}
|
||||
@@ -377,6 +401,7 @@ mod tests {
|
||||
&mood(NpcMood::Anxious), // Not Hostile
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Angry));
|
||||
}
|
||||
@@ -391,6 +416,7 @@ mod tests {
|
||||
&mood(NpcMood::Hostile),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Angry));
|
||||
}
|
||||
@@ -408,6 +434,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Guarded));
|
||||
}
|
||||
@@ -421,6 +448,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Moderate secret doesn't trigger Guarded
|
||||
assert_ne!(result, Some(TellCategory::Guarded));
|
||||
@@ -439,6 +467,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&positive_relationships()), // Trust > 3
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
@@ -452,6 +481,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&neutral_relationships()), // Trust = 0
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
@@ -465,6 +495,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None, // No relationships at all
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
@@ -479,6 +510,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&positive_relationships()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
@@ -496,6 +528,7 @@ mod tests {
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
@@ -509,6 +542,7 @@ mod tests {
|
||||
&mood(NpcMood::Anxious), // Not Hostile
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
@@ -572,4 +606,172 @@ mod tests {
|
||||
let state = world.get::<DerivedTellState>(entity).unwrap();
|
||||
assert_eq!(state.category, None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// D-082: KG-aware Friendly tell
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn kg_with_friendly_entity() -> KnowledgeGraph {
|
||||
use crate::simulation::movement::TilePosition;
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
|
||||
kg.set_relationship(&StableId(1), RelationshipState::Friendly);
|
||||
kg
|
||||
}
|
||||
|
||||
fn kg_with_known_entity() -> KnowledgeGraph {
|
||||
use crate::simulation::movement::TilePosition;
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
|
||||
kg.set_relationship(&StableId(1), RelationshipState::Known);
|
||||
kg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kg_friendly_relationship_triggers_friendly_tell() {
|
||||
let kg = kg_with_friendly_entity();
|
||||
let result = derive_category(
|
||||
&neutral_secret(),
|
||||
&tolerance(0, 50),
|
||||
&contentment(30),
|
||||
&mood(NpcMood::Neutral),
|
||||
None, // Relationships component doesn't matter when KG exists
|
||||
None,
|
||||
Some(&kg),
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kg_known_relationship_does_not_trigger_friendly_tell() {
|
||||
// Known != Friendly — only Friendly relationship triggers the tell
|
||||
let kg = kg_with_known_entity();
|
||||
let result = derive_category(
|
||||
&neutral_secret(),
|
||||
&tolerance(0, 50),
|
||||
&contentment(30),
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
Some(&kg),
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kg_empty_does_not_trigger_friendly_tell() {
|
||||
let kg = KnowledgeGraph::new();
|
||||
let result = derive_category(
|
||||
&neutral_secret(),
|
||||
&tolerance(0, 50),
|
||||
&contentment(30),
|
||||
&mood(NpcMood::Neutral),
|
||||
None,
|
||||
None,
|
||||
Some(&kg),
|
||||
);
|
||||
assert_ne!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_kg_falls_through_to_relationships_component() {
|
||||
// Without KG, the old behavior (Relationships component) should work
|
||||
let result = derive_category(
|
||||
&neutral_secret(),
|
||||
&tolerance(0, 50),
|
||||
&contentment(30),
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&positive_relationships()),
|
||||
None,
|
||||
None, // No KG
|
||||
);
|
||||
assert_eq!(result, Some(TellCategory::Friendly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kg_overrides_relationships_component() {
|
||||
// KG says no Friendly relationships, even though Relationships
|
||||
// component has trust > 3 — KG wins (D-082).
|
||||
let kg = kg_with_known_entity(); // Known, not Friendly
|
||||
let result = derive_category(
|
||||
&neutral_secret(),
|
||||
&tolerance(0, 50),
|
||||
&contentment(30),
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&positive_relationships()), // ground-truth says Friendly
|
||||
None,
|
||||
Some(&kg), // KG says Known (not Friendly)
|
||||
);
|
||||
assert_ne!(
|
||||
result,
|
||||
Some(TellCategory::Friendly),
|
||||
"KG should override Relationships component for Friendly tell"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Priority chain: verify ordering at boundaries (QA gap closure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nervous_beats_angry_when_both_conditions_met() {
|
||||
// NPC has: Major secret, stress past midpoint (Nervous), AND
|
||||
// low contentment + Hostile mood (Angry).
|
||||
// Priority 2 (Nervous) must win over Priority 3 (Angry).
|
||||
let result = derive_category(
|
||||
&major_secret(),
|
||||
&tolerance(80, 100), // stress*2=160 > 100 → Nervous
|
||||
&contentment(-50), // < -20 → Angry condition met
|
||||
&mood(NpcMood::Hostile), // Angry condition met
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(TellCategory::Nervous),
|
||||
"Nervous (priority 2) must beat Angry (priority 3)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angry_beats_guarded_when_both_conditions_met() {
|
||||
// NPC has: Major secret (Guarded), AND low contentment + Hostile (Angry).
|
||||
// Priority 3 (Angry) must win over Priority 4 (Guarded).
|
||||
// Note: stress is LOW so Nervous does not trigger.
|
||||
let result = derive_category(
|
||||
&major_secret(),
|
||||
&tolerance(10, 100), // Low stress — not Nervous
|
||||
&contentment(-30), // < -20 → Angry
|
||||
&mood(NpcMood::Hostile),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(TellCategory::Angry),
|
||||
"Angry (priority 3) must beat Guarded (priority 4)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_beats_friendly_when_both_conditions_met() {
|
||||
// NPC has: Major secret (Guarded), AND high contentment with positive
|
||||
// relationship (Friendly). Priority 4 (Guarded) must win over Priority 5.
|
||||
let result = derive_category(
|
||||
&major_secret(),
|
||||
&tolerance(0, 100), // Low stress — not Nervous
|
||||
&contentment(50), // > +20 → Friendly condition met
|
||||
&mood(NpcMood::Neutral),
|
||||
Some(&positive_relationships()), // Friendly condition met
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(TellCategory::Guarded),
|
||||
"Guarded (priority 4) must beat Friendly (priority 5)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Trait modifier system for unprompted disclosure (#173, D-081).
|
||||
//!
|
||||
//! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool,
|
||||
//! Stage 2 (HOW) weights line selection via delivery tags.
|
||||
//!
|
||||
//! Traits map to filter predicates via content-authorable YAML config —
|
||||
//! not hard-coded enum dispatch. Content authors define what each trait
|
||||
//! does to the candidate pool and which delivery tags it prefers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML-authored trait modifier config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full trait modifier configuration resource. Loaded from YAML.
|
||||
///
|
||||
/// Keys are trait names (lowercase, matching `PersonalityTrait` string
|
||||
/// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc.
|
||||
///
|
||||
/// BTreeMap for deterministic iteration (D-010 principle 4).
|
||||
#[derive(Resource, Debug, Clone, Default, Deserialize)]
|
||||
pub struct TraitModifierConfig {
|
||||
/// Trait name → modifier rules. Trait names are lowercase_snake_case.
|
||||
#[serde(default)]
|
||||
pub modifiers: BTreeMap<String, TraitModifier>,
|
||||
}
|
||||
|
||||
/// A single trait's filter and scoring rules.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct TraitModifier {
|
||||
/// Stage 1: candidate pool filter (WHAT gets disclosed).
|
||||
#[serde(default)]
|
||||
pub stage1: Stage1Filter,
|
||||
/// Stage 2: line pool scoring (HOW it's delivered).
|
||||
#[serde(default)]
|
||||
pub stage2: Stage2Scoring,
|
||||
}
|
||||
|
||||
/// Stage 1 filter predicates — modify the disclosure candidate pool.
|
||||
///
|
||||
/// Applied per-fact during candidate selection in `DisclosureCandidates`
|
||||
/// (#551). Multiple traits compose additively: if any trait includes a
|
||||
/// candidate that would otherwise be excluded, it's included.
|
||||
///
|
||||
/// Default values (all false/None) produce no modification to the
|
||||
/// baseline filter, which requires KnowsOf minimum confidence and
|
||||
/// includes all source types.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Stage1Filter {
|
||||
/// Minimum confidence to enter the disclosure pool.
|
||||
/// Parsed at load time: "suspects", "knows_of", "knows_details", "direct".
|
||||
/// None = use system default (KnowsOf).
|
||||
#[serde(default)]
|
||||
pub min_confidence: Option<String>,
|
||||
/// If true, exclude facts with `ToldBy` source (won't pass on rumors).
|
||||
/// Cautious trait behavior.
|
||||
#[serde(default)]
|
||||
pub exclude_told_by: bool,
|
||||
/// If true, exclude facts linked to entities with trust_level >= Real
|
||||
/// in NPC Relationships. Loyal trait behavior.
|
||||
#[serde(default)]
|
||||
pub exclude_high_trust_entities: bool,
|
||||
/// If true, override the witness inhibition gate. Talkative trait behavior.
|
||||
#[serde(default)]
|
||||
pub override_witness_inhibition: bool,
|
||||
}
|
||||
|
||||
/// Stage 2 scoring — influence line selection weighting.
|
||||
///
|
||||
/// Delivery tags in `IndexedDialogueLine.tags` are matched against
|
||||
/// the NPC's trait-derived preferred tags. Lines with matching tags
|
||||
/// receive a scoring bonus during Layer 4 selection.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Stage2Scoring {
|
||||
/// Preferred delivery tags for line selection weighting.
|
||||
/// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`.
|
||||
/// Lines with matching tags receive a scoring bonus.
|
||||
#[serde(default)]
|
||||
pub delivery_tags: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter predicate evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Stage1Filter {
|
||||
/// Parse the min_confidence string into a `KnowledgeConfidence` value.
|
||||
/// Returns `None` (use system default) for unparseable or absent values.
|
||||
pub fn min_confidence_level(&self) -> Option<KnowledgeConfidence> {
|
||||
self.min_confidence.as_deref().and_then(parse_confidence)
|
||||
}
|
||||
|
||||
/// Evaluate whether a fact passes this trait's Stage 1 filter.
|
||||
///
|
||||
/// Returns `false` if the fact should be excluded by this trait.
|
||||
/// The caller (#551) composes multiple trait filters: a fact is
|
||||
/// included if it passes the composite filter.
|
||||
pub fn allows_fact(&self, fact: &FactKnowledge) -> bool {
|
||||
// Check minimum confidence
|
||||
if let Some(min) = self.min_confidence_level() {
|
||||
if fact.confidence < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude ToldBy-source facts (Cautious behavior)
|
||||
if self.exclude_told_by {
|
||||
if matches!(fact.source, KnowledgeSource::ToldBy { .. }) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Stage2Scoring {
|
||||
/// Check if a line's tags contain any of this trait's preferred delivery tags.
|
||||
/// Returns the number of matching tags (0 = no bonus).
|
||||
pub fn tag_match_count(&self, line_tags: &[String]) -> usize {
|
||||
self.delivery_tags
|
||||
.iter()
|
||||
.filter(|dt| line_tags.contains(dt))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if a line has at least one matching delivery tag.
|
||||
pub fn has_matching_tag(&self, line_tags: &[String]) -> bool {
|
||||
self.tag_match_count(line_tags) > 0
|
||||
}
|
||||
}
|
||||
|
||||
impl TraitModifierConfig {
|
||||
/// Look up the modifier for a trait by name.
|
||||
pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> {
|
||||
self.modifiers.get(trait_name)
|
||||
}
|
||||
|
||||
/// Collect all Stage 2 delivery tags for a set of trait names.
|
||||
/// Returns a deduplicated, sorted list for deterministic matching.
|
||||
pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec<String> {
|
||||
let mut tags: Vec<String> = trait_names
|
||||
.iter()
|
||||
.filter_map(|name| self.modifiers.get(name.as_str()))
|
||||
.flat_map(|m| m.stage2.delivery_tags.iter().cloned())
|
||||
.collect();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
/// Check if any trait in the set overrides witness inhibition.
|
||||
pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool {
|
||||
trait_names.iter().any(|name| {
|
||||
self.modifiers
|
||||
.get(name.as_str())
|
||||
.is_some_and(|m| m.stage1.override_witness_inhibition)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if any trait in the set excludes high-trust entity facts.
|
||||
pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool {
|
||||
trait_names.iter().any(|name| {
|
||||
self.modifiers
|
||||
.get(name.as_str())
|
||||
.is_some_and(|m| m.stage1.exclude_high_trust_entities)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the most permissive (lowest) min_confidence across all traits.
|
||||
/// Returns None if no traits specify a minimum (use system default).
|
||||
pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option<KnowledgeConfidence> {
|
||||
trait_names
|
||||
.iter()
|
||||
.filter_map(|name| self.modifiers.get(name.as_str()))
|
||||
.filter_map(|m| m.stage1.min_confidence_level())
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a confidence string from YAML config to enum value.
|
||||
/// Delegates to `KnowledgeConfidence::try_from` (which accepts both
|
||||
/// camelCase and underscore forms) rather than duplicating the match.
|
||||
fn parse_confidence(s: &str) -> Option<KnowledgeConfidence> {
|
||||
KnowledgeConfidence::try_from(s).map_err(|e| {
|
||||
tracing::warn!("Unknown confidence level in trait config: {}", e);
|
||||
}).ok()
|
||||
}
|
||||
|
||||
/// Convert a `PersonalityTrait` to its lowercase YAML key.
|
||||
/// Used to look up trait modifiers from the config.
|
||||
pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str {
|
||||
match trait_val {
|
||||
super::PersonalityTrait::Cautious => "cautious",
|
||||
super::PersonalityTrait::Bold => "bold",
|
||||
super::PersonalityTrait::Honest => "honest",
|
||||
super::PersonalityTrait::Deceptive => "deceptive",
|
||||
super::PersonalityTrait::Compassionate => "compassionate",
|
||||
super::PersonalityTrait::Ruthless => "ruthless",
|
||||
super::PersonalityTrait::Curious => "curious",
|
||||
super::PersonalityTrait::Incurious => "incurious",
|
||||
super::PersonalityTrait::Social => "social",
|
||||
super::PersonalityTrait::Reclusive => "reclusive",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an NPC's personality trait list to YAML config keys.
|
||||
pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec<String> {
|
||||
traits.iter().map(|t| trait_to_key(t).to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge {
|
||||
FactKnowledge {
|
||||
confidence,
|
||||
source,
|
||||
state: crate::knowledge::types::KnowledgeState::Active,
|
||||
acquired_tick: 100,
|
||||
disclosure_blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cautious_config() -> TraitModifierConfig {
|
||||
let yaml = r#"
|
||||
modifiers:
|
||||
cautious:
|
||||
stage1:
|
||||
min_confidence: "knows_details"
|
||||
exclude_told_by: true
|
||||
stage2:
|
||||
delivery_tags: ["cautious_delivery"]
|
||||
gossipy:
|
||||
stage1:
|
||||
min_confidence: "suspects"
|
||||
stage2:
|
||||
delivery_tags: ["gossip_delivery", "casual_delivery"]
|
||||
loyal:
|
||||
stage1:
|
||||
exclude_high_trust_entities: true
|
||||
stage2:
|
||||
delivery_tags: ["professional_delivery"]
|
||||
talkative:
|
||||
stage1:
|
||||
override_witness_inhibition: true
|
||||
min_confidence: "suspects"
|
||||
stage2:
|
||||
delivery_tags: ["casual_delivery", "gossip_delivery"]
|
||||
"#;
|
||||
serde_yaml::from_str(yaml).expect("valid trait config YAML")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_config_from_yaml() {
|
||||
let config = cautious_config();
|
||||
assert_eq!(config.modifiers.len(), 4);
|
||||
assert!(config.modifiers.contains_key("cautious"));
|
||||
assert!(config.modifiers.contains_key("gossipy"));
|
||||
assert!(config.modifiers.contains_key("loyal"));
|
||||
assert!(config.modifiers.contains_key("talkative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_low_confidence() {
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let suspects_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
let details_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsDetails,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
|
||||
assert!(!cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects");
|
||||
assert!(cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_told_by() {
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let told_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsDetails,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(42),
|
||||
tick: 50,
|
||||
},
|
||||
);
|
||||
assert!(!cautious.allows_fact(&told_fact), "Cautious excludes ToldBy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_includes_suspects() {
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
let suspects_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talkative_overrides_witness_inhibition() {
|
||||
let config = cautious_config();
|
||||
let traits = vec!["talkative".to_string()];
|
||||
assert!(config.any_overrides_witness_inhibition(&traits));
|
||||
|
||||
let traits = vec!["cautious".to_string()];
|
||||
assert!(!config.any_overrides_witness_inhibition(&traits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loyal_excludes_high_trust() {
|
||||
let config = cautious_config();
|
||||
let traits = vec!["loyal".to_string()];
|
||||
assert!(config.any_excludes_high_trust(&traits));
|
||||
|
||||
let traits = vec!["gossipy".to_string()];
|
||||
assert!(!config.any_excludes_high_trust(&traits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_min_confidence_picks_most_permissive() {
|
||||
let config = cautious_config();
|
||||
// Gossipy (suspects) + Cautious (knows_details) → suspects wins
|
||||
let traits = vec!["gossipy".to_string(), "cautious".to_string()];
|
||||
assert_eq!(
|
||||
config.lowest_min_confidence(&traits),
|
||||
Some(KnowledgeConfidence::Suspects)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_tags_deduped_and_sorted() {
|
||||
let config = cautious_config();
|
||||
// Gossipy + Talkative both have "casual_delivery" and "gossip_delivery"
|
||||
let traits = vec!["gossipy".to_string(), "talkative".to_string()];
|
||||
let tags = config.delivery_tags_for(&traits);
|
||||
assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_tag_matching() {
|
||||
let config = cautious_config();
|
||||
let scoring = &config.modifiers["cautious"].stage2;
|
||||
|
||||
let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()];
|
||||
assert!(scoring.has_matching_tag(&line_tags));
|
||||
assert_eq!(scoring.tag_match_count(&line_tags), 1);
|
||||
|
||||
let no_match_tags = vec!["gossip_delivery".to_string()];
|
||||
assert!(!scoring.has_matching_tag(&no_match_tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_trait_returns_none() {
|
||||
let config = cautious_config();
|
||||
assert!(config.modifier_for("unknown_trait").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_to_key_roundtrip() {
|
||||
use super::super::PersonalityTrait;
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious");
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold");
|
||||
assert_eq!(trait_to_key(&PersonalityTrait::Social), "social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traits_to_keys_conversion() {
|
||||
use super::super::PersonalityTrait;
|
||||
let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social];
|
||||
let keys = traits_to_keys(&traits);
|
||||
assert_eq!(keys, vec!["cautious", "social"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_is_no_op() {
|
||||
let config = TraitModifierConfig::default();
|
||||
let traits = vec!["cautious".to_string()];
|
||||
assert!(!config.any_overrides_witness_inhibition(&traits));
|
||||
assert!(!config.any_excludes_high_trust(&traits));
|
||||
assert_eq!(config.lowest_min_confidence(&traits), None);
|
||||
assert!(config.delivery_tags_for(&traits).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_filter_allows_everything() {
|
||||
let filter = Stage1Filter::default();
|
||||
let fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(1),
|
||||
tick: 10,
|
||||
},
|
||||
);
|
||||
assert!(filter.allows_fact(&fact), "Default filter allows all facts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_confidence_values() {
|
||||
assert_eq!(parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects));
|
||||
assert_eq!(parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf));
|
||||
assert_eq!(parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails));
|
||||
assert_eq!(parse_confidence("direct"), Some(KnowledgeConfidence::Direct));
|
||||
assert_eq!(parse_confidence("invalid"), None);
|
||||
}
|
||||
|
||||
// --- Coverage gap closure tests ---
|
||||
|
||||
#[test]
|
||||
fn cautious_excludes_knows_of_below_threshold() {
|
||||
// Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails,
|
||||
// so a KnowsOf fact must be excluded (not just Suspects).
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let knows_of_fact = make_fact(
|
||||
KnowledgeConfidence::KnowsOf,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(
|
||||
!cautious.allows_fact(&knows_of_fact),
|
||||
"Cautious should exclude KnowsOf (below knows_details threshold)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cautious_allows_direct_confidence() {
|
||||
// Direct > KnowsDetails, so Direct passes cautious min_confidence.
|
||||
let config = cautious_config();
|
||||
let cautious = &config.modifiers["cautious"].stage1;
|
||||
|
||||
let direct_fact = make_fact(
|
||||
KnowledgeConfidence::Direct,
|
||||
KnowledgeSource::DirectObservation { tick: 50 },
|
||||
);
|
||||
assert!(
|
||||
cautious.allows_fact(&direct_fact),
|
||||
"Cautious should allow Direct confidence (above threshold)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_allows_all_confidence_levels() {
|
||||
// Gossipy min_confidence is "suspects" — all confidence levels pass.
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
for (confidence, label) in [
|
||||
(KnowledgeConfidence::Suspects, "Suspects"),
|
||||
(KnowledgeConfidence::KnowsOf, "KnowsOf"),
|
||||
(KnowledgeConfidence::KnowsDetails, "KnowsDetails"),
|
||||
(KnowledgeConfidence::Direct, "Direct"),
|
||||
] {
|
||||
let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 });
|
||||
assert!(
|
||||
gossipy.allows_fact(&fact),
|
||||
"Gossipy should allow {} confidence",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_personality_traits_map_to_unique_keys() {
|
||||
use super::super::PersonalityTrait;
|
||||
|
||||
let all_traits = vec![
|
||||
PersonalityTrait::Cautious,
|
||||
PersonalityTrait::Bold,
|
||||
PersonalityTrait::Honest,
|
||||
PersonalityTrait::Deceptive,
|
||||
PersonalityTrait::Compassionate,
|
||||
PersonalityTrait::Ruthless,
|
||||
PersonalityTrait::Curious,
|
||||
PersonalityTrait::Incurious,
|
||||
PersonalityTrait::Social,
|
||||
PersonalityTrait::Reclusive,
|
||||
];
|
||||
|
||||
let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect();
|
||||
|
||||
// All 10 traits produce a non-empty key
|
||||
for (trait_, key) in all_traits.iter().zip(keys.iter()) {
|
||||
assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_);
|
||||
}
|
||||
|
||||
// All keys are unique (no two traits share a key)
|
||||
let mut sorted = keys.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(
|
||||
sorted.len(),
|
||||
all_traits.len(),
|
||||
"All personality traits must map to distinct keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_multiple_tag_matches_counts_correctly() {
|
||||
// When a line has two matching delivery tags, tag_match_count returns 2.
|
||||
let config = cautious_config();
|
||||
// Talkative has: ["casual_delivery", "gossip_delivery"]
|
||||
let talkative_scoring = &config.modifiers["talkative"].stage2;
|
||||
|
||||
let line_tags = vec![
|
||||
"casual_delivery".to_string(),
|
||||
"gossip_delivery".to_string(),
|
||||
"unrelated_tag".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
talkative_scoring.tag_match_count(&line_tags),
|
||||
2,
|
||||
"Both delivery tags should match"
|
||||
);
|
||||
assert!(talkative_scoring.has_matching_tag(&line_tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gossipy_does_not_exclude_told_by() {
|
||||
// Gossipy has no exclude_told_by restriction — it should pass ToldBy facts.
|
||||
let config = cautious_config();
|
||||
let gossipy = &config.modifiers["gossipy"].stage1;
|
||||
|
||||
let told_fact = make_fact(
|
||||
KnowledgeConfidence::Suspects,
|
||||
KnowledgeSource::ToldBy {
|
||||
source_id: crate::knowledge::types::StableId(5),
|
||||
tick: 10,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
gossipy.allows_fact(&told_fact),
|
||||
"Gossipy should not exclude ToldBy-source facts"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ mod tests {
|
||||
world.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
|
||||
@@ -505,6 +505,7 @@ fn knowledge_without_position_not_shown() {
|
||||
state: crate::knowledge::KnowledgeState::Active,
|
||||
relationship: RelationshipState::PersonOfInterest,
|
||||
known_attributes: std::collections::BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -800,6 +801,7 @@ fn phase2_no_confront_without_knows_details() {
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: std::collections::BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2271,6 +2273,7 @@ fn access_rule_knowledge_gated_passes_with_matching_fact() {
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ pub fn check_contraband_scan(
|
||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: time.tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -463,6 +464,7 @@ mod tests {
|
||||
source: KnowledgeSource::DirectObservation { tick: 0 },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc
|
||||
use crate::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
use crate::content::types::KnowledgeGrant;
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
|
||||
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
|
||||
@@ -422,6 +426,7 @@ pub fn process_talk_interaction(
|
||||
time: Res<SimulationTime>,
|
||||
line_pool: Option<Res<LinePoolIndexResource>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
content_registry: Res<ContentEntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
@@ -442,6 +447,7 @@ pub fn process_talk_interaction(
|
||||
Option<&mut InteractionMemory>,
|
||||
Option<&NpcName>,
|
||||
Option<&NpcColorIndex>,
|
||||
Option<&KnowledgeGraph>,
|
||||
)>,
|
||||
) {
|
||||
let Some(line_pool) = line_pool else {
|
||||
@@ -462,8 +468,8 @@ pub fn process_talk_interaction(
|
||||
|
||||
let target = talk_request.target;
|
||||
|
||||
// Look up NPC dialogue profile, mood, interaction history, name, and color (#325)
|
||||
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
|
||||
// Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079)
|
||||
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) =
|
||||
npc_query.get_mut(target)
|
||||
else {
|
||||
tracing::debug!(
|
||||
@@ -541,6 +547,19 @@ pub fn process_talk_interaction(
|
||||
|
||||
cooldown.record(&line.id, time.tick);
|
||||
|
||||
// Knowledge grant (D-079): fire at line selection time, server-authoritative.
|
||||
if let Some(grant) = &line.knowledge_grant {
|
||||
emit_knowledge_grant(
|
||||
grant,
|
||||
player_entity,
|
||||
speaker_stable,
|
||||
&content_registry,
|
||||
npc_kg_opt,
|
||||
time.tick,
|
||||
&mut event_queue,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit IncompleteInteraction if overwriting an existing dialogue session
|
||||
if let Some(prev) = active_dialogue_opt {
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
@@ -594,6 +613,107 @@ pub fn process_talk_interaction(
|
||||
commands.entity(player_entity).remove::<TalkRequest>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge grant helper (D-079)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field.
|
||||
///
|
||||
/// Called at line selection time (server-authoritative, tick-stamped).
|
||||
/// Source is always `ToldBy { source_id: speaker_stable, tick }`.
|
||||
///
|
||||
/// Fact grants: dropped with tracing::warn! if the granting NPC's KG
|
||||
/// does not contain the fact (D-079 runtime guardrail).
|
||||
/// Entity grants: no guardrail — always emitted if entity_ref resolves.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_knowledge_grant(
|
||||
grant: &KnowledgeGrant,
|
||||
player_entity: Entity,
|
||||
speaker_stable: StableId,
|
||||
content_registry: &ContentEntityRegistry,
|
||||
npc_kg_opt: Option<&KnowledgeGraph>,
|
||||
tick: u64,
|
||||
event_queue: &mut crate::knowledge::KnowledgeEventQueue,
|
||||
) {
|
||||
let source = KnowledgeSource::ToldBy {
|
||||
source_id: speaker_stable,
|
||||
tick,
|
||||
};
|
||||
|
||||
match grant {
|
||||
KnowledgeGrant::Fact { fact_id, confidence } => {
|
||||
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fid = FactId(fact_id.clone());
|
||||
// Guardrail: NPC must know this fact to grant it (D-079).
|
||||
let npc_knows = npc_kg_opt
|
||||
.map(|kg| kg.knows_fact(&fid))
|
||||
.unwrap_or(false);
|
||||
if !npc_knows {
|
||||
tracing::warn!(
|
||||
"KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail",
|
||||
speaker_stable,
|
||||
fact_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick,
|
||||
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
|
||||
fact_id: fid,
|
||||
confidence: conf,
|
||||
}),
|
||||
source,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Entity grants have no "NPC knows this entity" guardrail (unlike Fact
|
||||
// grants above). This is intentional per D-079: entity grants introduce
|
||||
// NEW knowledge about an entity the NPC is talking about — the NPC
|
||||
// doesn't need to "know" the entity in their own KG to reference it
|
||||
// in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG.
|
||||
KnowledgeGrant::Entity {
|
||||
entity_ref,
|
||||
attributes,
|
||||
confidence,
|
||||
} => {
|
||||
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(target_id) = content_registry.resolve(entity_ref) else {
|
||||
tracing::warn!(
|
||||
"KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry",
|
||||
entity_ref
|
||||
);
|
||||
return;
|
||||
};
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick,
|
||||
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant {
|
||||
target_id,
|
||||
attributes: attributes.clone(),
|
||||
confidence: conf,
|
||||
}),
|
||||
source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: process_walk_away (D-064)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1330,6 +1450,7 @@ mod tests {
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ContentEntityRegistry>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
|
||||
@@ -1828,6 +1949,7 @@ mod tests {
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
@@ -14,8 +14,11 @@ pub mod inventory;
|
||||
pub mod listening;
|
||||
pub mod monologue;
|
||||
pub mod movement;
|
||||
pub mod npc_knowledge_transfer;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod poi;
|
||||
pub mod poi_discovery;
|
||||
pub mod rng;
|
||||
pub mod sound;
|
||||
pub mod spatial;
|
||||
@@ -42,6 +45,15 @@ impl Plugin for SimulationPlugin {
|
||||
.init_resource::<spatial::NaiveSpatialIndex>()
|
||||
.init_resource::<follow::FollowEndEventQueue>()
|
||||
.init_resource::<monologue::PostConversationQueue>()
|
||||
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
|
||||
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
|
||||
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
// transfer_npc_knowledge reads RelationshipGraph (also init by NpcPlugin) and
|
||||
// KnowledgeEventQueue (also init by KnowledgePlugin).
|
||||
// Init here so SimulationPlugin works standalone in tests without those plugins.
|
||||
.init_resource::<crate::npc::relationships::RelationshipGraph>()
|
||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -58,9 +70,14 @@ impl Plugin for SimulationPlugin {
|
||||
conversation::run_npc_conversations
|
||||
.after(movement::validate_movement)
|
||||
.before(sound::collect_sound_events),
|
||||
npc_knowledge_transfer::transfer_npc_knowledge
|
||||
.after(conversation::run_npc_conversations),
|
||||
sound::collect_sound_events
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
poi_discovery::discover_pois
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ use rand::Rng;
|
||||
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::content::ContentStoreResource;
|
||||
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -455,7 +457,10 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri
|
||||
"hear_sound" => HEAR_SOUND_LINES,
|
||||
"witness_interaction" => WITNESS_INTERACTION_LINES,
|
||||
"post_conversation" => POST_CONVERSATION_LINES,
|
||||
_ => OBSERVE_NPC_LINES,
|
||||
unknown => {
|
||||
tracing::warn!("select_hardcoded_fallback: unrecognized trigger '{}', using observe_npc", unknown);
|
||||
OBSERVE_NPC_LINES
|
||||
}
|
||||
};
|
||||
let index = rng.random_range(0..lines.len());
|
||||
(lines[index].0.to_string(), lines[index].1.to_string())
|
||||
@@ -738,6 +743,102 @@ pub fn trigger_monologue(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contradiction monologue trigger (#550, D-083)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hardcoded v0.1 contradiction monologue template lines.
|
||||
/// Placeholders: `{source}` = NPC who gave false info, `{subject}` = NPC whose
|
||||
/// position was contradicted. Hand-authored Sera/Kael lines come from copy team (#552).
|
||||
const CONTRADICTION_TEMPLATE_LINES: &[&str] = &[
|
||||
"{source} told me where {subject} would be. They were wrong.",
|
||||
"Something's off. {source} sent me the wrong way for {subject}.",
|
||||
"{subject} wasn't where {source} said. Was that a mistake — or a lie?",
|
||||
];
|
||||
|
||||
/// Resolve a display name from a StableId via EntityRegistry + NpcName query.
|
||||
/// Falls back to `"#<id>"` when the entity is not registered or has no NpcName.
|
||||
/// Called from tests and available for future triggers needing live name resolution.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn resolve_name(
|
||||
stable_id: crate::knowledge::types::StableId,
|
||||
registry: &EntityRegistry,
|
||||
names: &Query<&NpcName>,
|
||||
) -> String {
|
||||
registry
|
||||
.to_entity(&stable_id)
|
||||
.and_then(|e| names.get(e).ok())
|
||||
.map(|n| n.0.clone())
|
||||
.unwrap_or_else(|| format!("#{}", stable_id.0))
|
||||
}
|
||||
|
||||
/// Contradiction monologue trigger (#550, D-083).
|
||||
///
|
||||
/// Drains ContradictionDetectedQueue once per tick. On first contradiction,
|
||||
/// fires a monologue line with the pre-resolved source and subject names.
|
||||
/// Bypasses normal cooldown (event-driven), but updates last_fired_tick.
|
||||
///
|
||||
/// Relationship shift (PersonOfInterest) is already done by `process_knowledge_events`
|
||||
/// before this system runs. This system is a pure consumer of the resolved strings.
|
||||
///
|
||||
/// System ordering: after trigger_event_monologue, before compute_observer_snapshot.
|
||||
pub fn process_contradiction_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
_registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
||||
_npc_names: Query<&NpcName>,
|
||||
mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With<PlayerCharacter>>,
|
||||
) {
|
||||
// _registry and _npc_names are available for future triggers needing live name resolution
|
||||
// via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload.
|
||||
|
||||
if contradiction_queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((mut buffer, mut state)) = player_query.single_mut() else {
|
||||
contradiction_queue.drain();
|
||||
return;
|
||||
};
|
||||
|
||||
// Don't override a higher-priority monologue that already fired this tick.
|
||||
if buffer.event.is_some() {
|
||||
contradiction_queue.drain();
|
||||
return;
|
||||
}
|
||||
|
||||
let events = contradiction_queue.drain();
|
||||
// Process only the first contradiction per tick (first-in wins).
|
||||
let Some(event) = events.into_iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let template_idx = rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
|
||||
let text = CONTRADICTION_TEMPLATE_LINES[template_idx]
|
||||
.replace("{source}", &event.source_display_name)
|
||||
.replace("{subject}", &event.subject_display_name);
|
||||
|
||||
let id = format!("contradiction_{:02}", template_idx + 1);
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
text,
|
||||
duration_seconds: DISPLAY_DURATION,
|
||||
});
|
||||
|
||||
state.shown_ids.insert(id.clone());
|
||||
state.last_fired_tick = time.tick;
|
||||
|
||||
tracing::debug!(
|
||||
"Contradiction monologue fired: id={}, source={}, subject={}, tick={}",
|
||||
id,
|
||||
event.source_display_name,
|
||||
event.subject_display_name,
|
||||
time.tick,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2183,4 +2284,313 @@ mod tests {
|
||||
"last_observation_tick should track highest event tick"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// process_contradiction_monologue tests (#550, D-083)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn setup_contradiction_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.insert_resource(ContradictionDetectedQueue::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn spawn_contradiction_player(world: &mut bevy_ecs::world::World) -> bevy_ecs::entity::Entity {
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(0, 0, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
)).id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_monologue_fires_with_resolved_names() {
|
||||
// ContradictionDetectedQueue has an event with pre-resolved names.
|
||||
// process_contradiction_monologue should fire a monologue line containing both names.
|
||||
let mut world = setup_contradiction_world();
|
||||
let player = spawn_contradiction_player(&mut world);
|
||||
|
||||
// Advance past tick 0 so last_fired_tick=0 cooldown doesn't block
|
||||
world.resource_mut::<SimulationTime>().tick = 500;
|
||||
|
||||
// Populate the contradiction queue with pre-resolved names
|
||||
world.resource_mut::<ContradictionDetectedQueue>().push(
|
||||
crate::knowledge::ContradictionDetectedEvent {
|
||||
observer: player,
|
||||
target: crate::knowledge::types::StableId(2),
|
||||
claim: crate::knowledge::types::ContradictionClaim {
|
||||
told_by: crate::knowledge::types::StableId(1),
|
||||
told_tick: 100,
|
||||
claimed_position: TilePosition::new(5, 5, 0),
|
||||
observed_position: TilePosition::new(10, 10, 0),
|
||||
detected_tick: 500,
|
||||
},
|
||||
source_display_name: "Sera".to_string(),
|
||||
subject_display_name: "Kael".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_contradiction_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
assert!(buf.event.is_some(), "contradiction monologue should fire");
|
||||
let event = buf.event.as_ref().unwrap();
|
||||
assert!(
|
||||
event.text.contains("Sera"),
|
||||
"monologue text should mention the source: got '{}'",
|
||||
event.text
|
||||
);
|
||||
assert!(
|
||||
event.text.contains("Kael"),
|
||||
"monologue text should mention the subject: got '{}'",
|
||||
event.text
|
||||
);
|
||||
// ID should be contradiction_01 / _02 / _03
|
||||
assert!(
|
||||
event.id.starts_with("contradiction_"),
|
||||
"monologue id should be contradiction_NN: got '{}'",
|
||||
event.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_monologue_does_not_override_existing_buffer() {
|
||||
// If MonologueBuffer already has an event, contradiction must not clobber it.
|
||||
let mut world = setup_contradiction_world();
|
||||
let player = spawn_contradiction_player(&mut world);
|
||||
|
||||
// Pre-fill buffer with a higher-priority monologue
|
||||
world.get_mut::<MonologueBuffer>(player).unwrap().set(MonologueEvent {
|
||||
id: "prior_event".to_string(),
|
||||
text: "Something already fired.".to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
|
||||
world.resource_mut::<ContradictionDetectedQueue>().push(
|
||||
crate::knowledge::ContradictionDetectedEvent {
|
||||
observer: player,
|
||||
target: crate::knowledge::types::StableId(2),
|
||||
claim: crate::knowledge::types::ContradictionClaim {
|
||||
told_by: crate::knowledge::types::StableId(1),
|
||||
told_tick: 100,
|
||||
claimed_position: TilePosition::new(5, 5, 0),
|
||||
observed_position: TilePosition::new(10, 10, 0),
|
||||
detected_tick: 100,
|
||||
},
|
||||
source_display_name: "Sera".to_string(),
|
||||
subject_display_name: "Kael".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_contradiction_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Buffer should still have the prior event
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
let event = buf.event.as_ref().unwrap();
|
||||
assert_eq!(event.id, "prior_event", "prior monologue should not be overridden");
|
||||
|
||||
// Queue should have been drained regardless
|
||||
assert!(
|
||||
world.resource::<ContradictionDetectedQueue>().is_empty(),
|
||||
"queue should be drained even when buffer is occupied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_monologue_drains_queue_when_no_player() {
|
||||
// If there's no player entity, the queue must still be drained (no panic).
|
||||
let mut world = setup_contradiction_world();
|
||||
// No player spawned
|
||||
|
||||
let fake_world_entity = world.spawn_empty().id();
|
||||
world.resource_mut::<ContradictionDetectedQueue>().push(
|
||||
crate::knowledge::ContradictionDetectedEvent {
|
||||
observer: fake_world_entity,
|
||||
target: crate::knowledge::types::StableId(2),
|
||||
claim: crate::knowledge::types::ContradictionClaim {
|
||||
told_by: crate::knowledge::types::StableId(1),
|
||||
told_tick: 100,
|
||||
claimed_position: TilePosition::new(1, 1, 0),
|
||||
observed_position: TilePosition::new(5, 5, 0),
|
||||
detected_tick: 100,
|
||||
},
|
||||
source_display_name: "Unknown".to_string(),
|
||||
subject_display_name: "Unknown".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_contradiction_monologue);
|
||||
schedule.run(&mut world); // should not panic
|
||||
|
||||
assert!(
|
||||
world.resource::<ContradictionDetectedQueue>().is_empty(),
|
||||
"queue should be drained even without a player"
|
||||
);
|
||||
let _ = fake_world_entity; // suppress unused warning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_friend_arc_integration_full_sequence() {
|
||||
// THE FRIEND arc integration test (#550, D-083).
|
||||
//
|
||||
// Tick T1: KnowledgeGranted creates ToldBy entry (Kael at (5,5), told by Sera)
|
||||
// Tick T2: DirectObservation fires ContradictionDetected (Kael at (10,10))
|
||||
// → relationship shift: Sera becomes PersonOfInterest in player's KG
|
||||
// → ContradictionDetectedEvent pushed with resolved names
|
||||
// Tick T3: process_contradiction_monologue fires monologue with Sera/Kael names
|
||||
//
|
||||
// Tests the full D-083 event chain end-to-end.
|
||||
use crate::knowledge::{
|
||||
EntityRegistry, KnowledgeGraph, KnowledgeEventQueue, KnowledgeEventType,
|
||||
};
|
||||
use crate::knowledge::events::{process_knowledge_events, KnowledgeEvent};
|
||||
use crate::knowledge::registry::StableEntityId;
|
||||
use crate::knowledge::types::{
|
||||
EntityKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
|
||||
RelationshipState, StableId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(7));
|
||||
world.init_resource::<ContradictionDetectedQueue>();
|
||||
|
||||
// Registry
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Spawn NPCs with NpcName components
|
||||
let sera_entity = world.spawn(NpcName("Sera".to_string())).id();
|
||||
let kael_entity = world.spawn((
|
||||
NpcName("Kael".to_string()),
|
||||
TilePosition::new(10, 10, 0),
|
||||
)).id();
|
||||
|
||||
let sera_sid = registry.register(sera_entity);
|
||||
let kael_sid = registry.register(kael_entity);
|
||||
|
||||
// Spawn player with KnowledgeGraph
|
||||
let mut player_kg = KnowledgeGraph::new();
|
||||
|
||||
// Tick T1: Pre-populate KG with ToldBy entry — Sera told us Kael is at (5,5)
|
||||
player_kg.entities.insert(
|
||||
kael_sid,
|
||||
EntityKnowledge {
|
||||
last_known_position: Some(TilePosition::new(5, 5, 0)),
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 100,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: sera_sid,
|
||||
tick: 100,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
known_attributes: BTreeMap::new(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
|
||||
let player = world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(0, 0, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
player_kg,
|
||||
StableEntityId(StableId(999)),
|
||||
)).id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// Tick T2: Push DirectObservation of Kael at (10,10) — contradicts (5,5)
|
||||
let tick_t2 = 200u64;
|
||||
world.resource_mut::<SimulationTime>().tick = tick_t2;
|
||||
|
||||
let mut ke_queue = KnowledgeEventQueue::default();
|
||||
ke_queue.push(KnowledgeEvent {
|
||||
observer: player,
|
||||
tick: tick_t2,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: kael_entity,
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
},
|
||||
});
|
||||
world.insert_resource(ke_queue);
|
||||
|
||||
let mut schedule_t2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule_t2.add_systems(process_knowledge_events);
|
||||
schedule_t2.run(&mut world);
|
||||
|
||||
// Verify contradiction was detected and queued
|
||||
{
|
||||
let cq = world.resource::<ContradictionDetectedQueue>();
|
||||
assert!(!cq.is_empty(), "contradiction should be in queue after T2");
|
||||
}
|
||||
|
||||
// Verify Sera became PersonOfInterest in player's KG
|
||||
{
|
||||
let kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
let sera_entry = kg.entity_knowledge(&sera_sid);
|
||||
assert!(
|
||||
sera_entry.is_some(),
|
||||
"Sera should have an entry in player's KG after contradiction"
|
||||
);
|
||||
assert_eq!(
|
||||
sera_entry.unwrap().relationship,
|
||||
RelationshipState::PersonOfInterest,
|
||||
"Sera should be PersonOfInterest after giving false location info"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify ContradictionDetectedEvent has correct pre-resolved names
|
||||
{
|
||||
// Drain to inspect event contents, then re-push for the monologue consumer.
|
||||
let mut events = world.resource_mut::<ContradictionDetectedQueue>().drain();
|
||||
assert_eq!(events.len(), 1, "should have exactly one contradiction event");
|
||||
let event = &events[0];
|
||||
assert_eq!(event.source_display_name, "Sera");
|
||||
assert_eq!(event.subject_display_name, "Kael");
|
||||
// Re-push so process_contradiction_monologue can consume it on T3.
|
||||
let event = events.remove(0);
|
||||
world.resource_mut::<ContradictionDetectedQueue>().push(event);
|
||||
}
|
||||
|
||||
// Tick T3: Run process_contradiction_monologue
|
||||
world.resource_mut::<SimulationTime>().tick = 300;
|
||||
|
||||
let mut schedule_t3 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule_t3.add_systems(process_contradiction_monologue);
|
||||
schedule_t3.run(&mut world);
|
||||
|
||||
// Verify monologue fired with both names
|
||||
let buf = world.get::<MonologueBuffer>(player).unwrap();
|
||||
assert!(buf.event.is_some(), "monologue should fire on T3");
|
||||
let mono_event = buf.event.as_ref().unwrap();
|
||||
assert!(
|
||||
mono_event.text.contains("Sera"),
|
||||
"monologue text should mention Sera: '{}'",
|
||||
mono_event.text
|
||||
);
|
||||
assert!(
|
||||
mono_event.text.contains("Kael"),
|
||||
"monologue text should mention Kael: '{}'",
|
||||
mono_event.text
|
||||
);
|
||||
|
||||
// Verify queue is drained after monologue fires
|
||||
assert!(
|
||||
world.resource::<ContradictionDetectedQueue>().is_empty(),
|
||||
"queue should be empty after monologue consumed the event"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
//! NPC-to-NPC knowledge transfer system (D-080, ticket #548).
|
||||
//!
|
||||
//! When an NPC-to-NPC conversation starts (`Added<NpcConversation>`), this system
|
||||
//! transfers a sample of the speaker's KG entries to the listener (ToldBy source).
|
||||
//! Transfer eligibility and volume are gated by the trust level between the two NPCs
|
||||
//! from `RelationshipGraph`. The confidence cap (max KnowsOf) ensures information
|
||||
//! degrades as it propagates through social networks.
|
||||
//!
|
||||
//! Player overhear: if the player is within VOICE_RANGE_TILES of the conversation,
|
||||
//! they gain entity-level knowledge about both NPCs at Suspects confidence
|
||||
//! (Heard source). This models ambient social information gathering.
|
||||
//!
|
||||
//! Closes Q-024: NPC-to-NPC propagation rate.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::knowledge::registry::StableEntityId;
|
||||
use crate::knowledge::types::{
|
||||
EntityKnowledge, FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
|
||||
RelationshipState, SoundRange, StableId,
|
||||
};
|
||||
use crate::knowledge::{
|
||||
KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, ProcessedEntityGrant,
|
||||
ProcessedKnowledgeGrant,
|
||||
};
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcConversation;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Range within which player can overhear NPC-to-NPC knowledge exchange.
|
||||
/// Matches VOICE_RANGE_TILES in conversation.rs (D-018 Medium = 8 tiles).
|
||||
const VOICE_RANGE_TILES: u32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust tier (NPC-to-NPC, D-080)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trust tier for NPC-to-NPC knowledge transfer.
|
||||
/// Derived from `RelationshipEdge.trust` (i8 in -10..+10).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NpcTransferTier {
|
||||
/// trust < 0: no knowledge flows.
|
||||
None,
|
||||
/// trust 0..=2: Active facts at KnowsOf+ confidence only.
|
||||
Surface,
|
||||
/// trust 3..=6: Active facts at any confidence + entity observations.
|
||||
Real,
|
||||
/// trust 7..=10: All Active entries (facts at any confidence + all entities).
|
||||
Secret,
|
||||
}
|
||||
|
||||
/// Map a raw trust score to the NPC transfer tier (D-080 boundaries).
|
||||
fn trust_to_tier(trust: i8) -> NpcTransferTier {
|
||||
if trust < 0 {
|
||||
NpcTransferTier::None
|
||||
} else if trust >= 7 {
|
||||
NpcTransferTier::Secret
|
||||
} else if trust >= 3 {
|
||||
NpcTransferTier::Real
|
||||
} else {
|
||||
NpcTransferTier::Surface
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfer candidate pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tagged entry in the transfer candidate pool.
|
||||
/// Used to sort facts and entity entries by recency before drawing.
|
||||
enum TransferCandidate {
|
||||
Fact {
|
||||
id: FactId,
|
||||
confidence: KnowledgeConfidence,
|
||||
acquired_tick: u64,
|
||||
},
|
||||
Entity {
|
||||
id: StableId,
|
||||
ek: EntityKnowledge,
|
||||
},
|
||||
}
|
||||
|
||||
impl TransferCandidate {
|
||||
/// Sort key: most recently updated entries are preferred.
|
||||
fn sort_key(&self) -> u64 {
|
||||
match self {
|
||||
Self::Fact { acquired_tick, .. } => *acquired_tick,
|
||||
Self::Entity { ek, .. } => ek.last_updated_tick,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: transfer NPC knowledge at conversation start (D-080).
|
||||
///
|
||||
/// Fires once per conversation (triggered by `Added<NpcConversation>`).
|
||||
/// Reads the trust level between the two NPCs from `RelationshipGraph` and
|
||||
/// transfers a sample (1–3 entries) of the speaker's eligible KG entries to
|
||||
/// the listener's KG with `ToldBy` source and confidence capped at `KnowsOf`.
|
||||
///
|
||||
/// If the player is within VOICE_RANGE_TILES, they gain entity-level knowledge
|
||||
/// about both NPCs at `Suspects` confidence (`Heard` source).
|
||||
///
|
||||
/// System ordering: after(run_npc_conversations).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn transfer_npc_knowledge(
|
||||
time: Res<SimulationTime>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
relationship_graph: Res<RelationshipGraph>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
// NPCs that just started a conversation — Added fires once per conversation.
|
||||
new_conv_query: Query<
|
||||
(Entity, &NpcConversation, &TilePosition, Option<&StableEntityId>),
|
||||
(With<Npc>, With<ActiveSim>, Added<NpcConversation>),
|
||||
>,
|
||||
// Read-only StableEntityId on NPC partner (distinct query, no KG conflict).
|
||||
partner_sid_query: Query<Option<&StableEntityId>, With<Npc>>,
|
||||
// Mutable KG access — get_many_mut for dual-entity borrow safety.
|
||||
mut kg_query: Query<&mut KnowledgeGraph>,
|
||||
// Player position for overhear radius check.
|
||||
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
) {
|
||||
let tick = time.tick;
|
||||
|
||||
for (speaker_entity, conv, speaker_pos, speaker_sid_opt) in new_conv_query.iter() {
|
||||
let partner_entity = conv.partner;
|
||||
|
||||
// --- Resolve stable IDs ---
|
||||
|
||||
let speaker_sid = match speaker_sid_opt.map(|s| s.0) {
|
||||
Some(sid) => sid,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"NPC transfer: speaker {:?} has no StableEntityId, skipping",
|
||||
speaker_entity
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let partner_sid = match partner_sid_query
|
||||
.get(partner_entity)
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|s| s.0))
|
||||
{
|
||||
Some(sid) => sid,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"NPC transfer: partner {:?} has no StableEntityId, skipping",
|
||||
partner_entity
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Trust level → transfer tier ---
|
||||
//
|
||||
// Use the speaker's trust in the listener: the speaker decides what to share
|
||||
// based on how much they trust this particular person. Defaults to 0 (Surface)
|
||||
// when no relationship edge exists — strangers can still overhear ambient facts.
|
||||
let trust = relationship_graph
|
||||
.get_relationship(&speaker_sid, &partner_sid)
|
||||
.map(|e| e.trust)
|
||||
.unwrap_or(0);
|
||||
let tier = trust_to_tier(trust);
|
||||
|
||||
if tier == NpcTransferTier::None {
|
||||
tracing::debug!(
|
||||
"NPC transfer: trust={} (None tier) between {:?} ↔ {:?}, skipping",
|
||||
trust,
|
||||
speaker_entity,
|
||||
partner_entity
|
||||
);
|
||||
// Still do player overhear (NPCs are audibly talking even if not exchanging info)
|
||||
emit_player_overhear_grants(
|
||||
&player_query,
|
||||
speaker_pos,
|
||||
tick,
|
||||
speaker_sid,
|
||||
partner_sid,
|
||||
&mut event_queue,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Dual-mutable KG access ---
|
||||
// Transfer is one-directional per conversation tick: speaker → partner.
|
||||
// If both participants are Active NPCs, each fires as "speaker" in
|
||||
// separate conversation pairs (run_npc_conversations creates symmetric
|
||||
// pairs), so both directions are covered across two iterations.
|
||||
|
||||
let Ok([speaker_kg, mut partner_kg]) =
|
||||
kg_query.get_many_mut([speaker_entity, partner_entity])
|
||||
else {
|
||||
tracing::debug!(
|
||||
"NPC transfer: couldn't get KGs for {:?} / {:?}, skipping",
|
||||
speaker_entity,
|
||||
partner_entity
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// --- Build candidate pool from speaker's KG ---
|
||||
|
||||
let mut candidates: Vec<TransferCandidate> = Vec::new();
|
||||
|
||||
// Facts: always eligible (filtered by tier and disclosure_blocked)
|
||||
for (fid, fk) in speaker_kg.facts.iter() {
|
||||
if fk.disclosure_blocked || fk.state != KnowledgeState::Active {
|
||||
continue;
|
||||
}
|
||||
let eligible = match tier {
|
||||
NpcTransferTier::Surface => fk.confidence >= KnowledgeConfidence::KnowsOf,
|
||||
NpcTransferTier::Real | NpcTransferTier::Secret => true,
|
||||
NpcTransferTier::None => unreachable!("None tier handled above"),
|
||||
};
|
||||
if eligible {
|
||||
candidates.push(TransferCandidate::Fact {
|
||||
id: fid.clone(),
|
||||
confidence: fk.confidence,
|
||||
acquired_tick: fk.acquired_tick,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Entity observations: Real and Secret tiers only
|
||||
if tier == NpcTransferTier::Real || tier == NpcTransferTier::Secret {
|
||||
for (sid, ek) in speaker_kg.entities.iter() {
|
||||
if ek.state != KnowledgeState::Active {
|
||||
continue;
|
||||
}
|
||||
candidates.push(TransferCandidate::Entity {
|
||||
id: *sid,
|
||||
ek: ek.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
tracing::debug!(
|
||||
"NPC transfer: no eligible entries in speaker {:?} KG at {:?} tier",
|
||||
speaker_entity,
|
||||
tier
|
||||
);
|
||||
emit_player_overhear_grants(
|
||||
&player_query,
|
||||
speaker_pos,
|
||||
tick,
|
||||
speaker_sid,
|
||||
partner_sid,
|
||||
&mut event_queue,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by most recently updated (deterministic: descending tick, stable by BTreeMap key order)
|
||||
candidates.sort_by(|a, b| b.sort_key().cmp(&a.sort_key()));
|
||||
|
||||
// Take top 1–3 entries by recency (random count, deterministic selection).
|
||||
// The random element is HOW MANY facts transfer, not WHICH ones.
|
||||
let count = rng.rng.random_range(1u32..=3u32) as usize;
|
||||
let count = count.min(candidates.len());
|
||||
|
||||
tracing::debug!(
|
||||
"NPC transfer: {:?} → {:?}, tier={:?}, trust={}, drawing {}/{}",
|
||||
speaker_entity,
|
||||
partner_entity,
|
||||
tier,
|
||||
trust,
|
||||
count,
|
||||
candidates.len(),
|
||||
);
|
||||
|
||||
// --- Apply transfers to partner's KG ---
|
||||
|
||||
for candidate in candidates.into_iter().take(count) {
|
||||
match candidate {
|
||||
TransferCandidate::Fact { id, confidence, .. } => {
|
||||
// Confidence cap: speaker's knowledge degrades to at most KnowsOf.
|
||||
let capped = confidence.min(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Upgrade-only: never downgrade existing knowledge.
|
||||
let should_write = partner_kg
|
||||
.facts
|
||||
.get(&id)
|
||||
.map(|existing| existing.confidence < capped)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_write {
|
||||
partner_kg.facts.insert(
|
||||
id.clone(),
|
||||
FactKnowledge {
|
||||
confidence: capped,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: speaker_sid,
|
||||
tick,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
"NPC transfer: fact {:?} at {:?} → {:?}",
|
||||
id,
|
||||
capped,
|
||||
partner_entity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransferCandidate::Entity { id, ek } => {
|
||||
// Confidence cap: at most KnowsOf.
|
||||
let capped = ek.confidence.min(KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Preserve existing relationship state if the partner already knows this entity.
|
||||
let (should_write, existing_relationship) =
|
||||
match partner_kg.entities.get(&id) {
|
||||
None => (true, RelationshipState::Unknown),
|
||||
Some(existing) => {
|
||||
(existing.confidence < capped, existing.relationship)
|
||||
}
|
||||
};
|
||||
|
||||
if should_write {
|
||||
partner_kg.entities.insert(
|
||||
id,
|
||||
EntityKnowledge {
|
||||
last_known_position: ek.last_known_position,
|
||||
last_observed_tick: ek.last_observed_tick,
|
||||
last_updated_tick: tick,
|
||||
confidence: capped,
|
||||
source: KnowledgeSource::ToldBy {
|
||||
source_id: speaker_sid,
|
||||
tick,
|
||||
},
|
||||
state: KnowledgeState::Active,
|
||||
relationship: existing_relationship,
|
||||
known_attributes: ek.known_attributes.clone(),
|
||||
contradicted_claim: None,
|
||||
},
|
||||
);
|
||||
tracing::debug!(
|
||||
"NPC transfer: entity {:?} at {:?} → {:?}",
|
||||
id,
|
||||
capped,
|
||||
partner_entity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Player overhear grants ---
|
||||
|
||||
emit_player_overhear_grants(
|
||||
&player_query,
|
||||
speaker_pos,
|
||||
tick,
|
||||
speaker_sid,
|
||||
partner_sid,
|
||||
&mut event_queue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `Heard` entity grants to all players within VOICE_RANGE_TILES of a conversation.
|
||||
///
|
||||
/// Called regardless of transfer tier — even if the NPCs aren't sharing information,
|
||||
/// the player can still learn that these two entities exist from overhearing them talk.
|
||||
fn emit_player_overhear_grants(
|
||||
player_query: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
speaker_pos: &TilePosition,
|
||||
tick: u64,
|
||||
speaker_sid: StableId,
|
||||
partner_sid: StableId,
|
||||
event_queue: &mut KnowledgeEventQueue,
|
||||
) {
|
||||
for (player_entity, player_pos) in player_query.iter() {
|
||||
let distance = speaker_pos
|
||||
.manhattan_distance(player_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
|
||||
if distance <= VOICE_RANGE_TILES {
|
||||
// Player overhears both participants — learns they exist at Suspects level.
|
||||
for npc_sid in [speaker_sid, partner_sid] {
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick,
|
||||
event_type: KnowledgeEventType::KnowledgeGranted {
|
||||
grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant {
|
||||
target_id: npc_sid,
|
||||
attributes: BTreeMap::new(),
|
||||
confidence: KnowledgeConfidence::Suspects,
|
||||
}),
|
||||
source: KnowledgeSource::Heard {
|
||||
tick,
|
||||
range: SoundRange::Medium,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
tracing::debug!(
|
||||
"Player {:?} overhears conversation at distance {} tiles (D-080)",
|
||||
player_entity,
|
||||
distance
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeState, StableId};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use crate::npc::RelationshipKind;
|
||||
use crate::simulation::conversation::NpcConversation;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
fn build_test_world() -> App {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<SimulationTime>();
|
||||
app.insert_resource(SimRng::new(42));
|
||||
app.init_resource::<EntityRegistry>();
|
||||
app.init_resource::<RelationshipGraph>();
|
||||
app.init_resource::<KnowledgeEventQueue>();
|
||||
app.add_systems(Update, transfer_npc_knowledge);
|
||||
app
|
||||
}
|
||||
|
||||
/// Spawn a minimal NPC entity with the required components.
|
||||
/// The StableEntityId component is what the transfer system reads — no registry needed.
|
||||
fn spawn_npc(world: &mut World, sid: StableId, kg: KnowledgeGraph) -> Entity {
|
||||
world
|
||||
.spawn((
|
||||
crate::npc::Npc,
|
||||
ActiveSim,
|
||||
StableEntityId(sid),
|
||||
TilePosition { x: 0, y: 0, z: 0 },
|
||||
kg,
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
fn make_fact_kg(fact_id: &str, confidence: KnowledgeConfidence) -> KnowledgeGraph {
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.facts.insert(
|
||||
FactId(fact_id.to_string()),
|
||||
FactKnowledge {
|
||||
confidence,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 1,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
kg
|
||||
}
|
||||
|
||||
// --- Trust tier mapping ---
|
||||
|
||||
#[test]
|
||||
fn trust_none_below_zero() {
|
||||
assert_eq!(trust_to_tier(-1), NpcTransferTier::None);
|
||||
assert_eq!(trust_to_tier(-10), NpcTransferTier::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_surface_zero_to_two() {
|
||||
assert_eq!(trust_to_tier(0), NpcTransferTier::Surface);
|
||||
assert_eq!(trust_to_tier(2), NpcTransferTier::Surface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_real_three_to_six() {
|
||||
assert_eq!(trust_to_tier(3), NpcTransferTier::Real);
|
||||
assert_eq!(trust_to_tier(6), NpcTransferTier::Real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_secret_seven_plus() {
|
||||
assert_eq!(trust_to_tier(7), NpcTransferTier::Secret);
|
||||
assert_eq!(trust_to_tier(10), NpcTransferTier::Secret);
|
||||
}
|
||||
|
||||
// --- Confidence cap ---
|
||||
|
||||
#[test]
|
||||
fn confidence_cap_downgrades_details_to_knows_of() {
|
||||
let capped = KnowledgeConfidence::KnowsDetails.min(KnowledgeConfidence::KnowsOf);
|
||||
assert_eq!(capped, KnowledgeConfidence::KnowsOf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_cap_preserves_lower_confidence() {
|
||||
let capped = KnowledgeConfidence::Suspects.min(KnowledgeConfidence::KnowsOf);
|
||||
assert_eq!(capped, KnowledgeConfidence::Suspects);
|
||||
}
|
||||
|
||||
// --- Surface tier: KnowsOf+ facts only ---
|
||||
|
||||
#[test]
|
||||
fn surface_tier_transfers_knows_of_fact() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
|
||||
let kg_b = KnowledgeGraph::new();
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
// Trust = 1 → Surface tier
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 1,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Start conversation — tick 0, so started_tick == 0
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
// Partner should now know the fact
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
assert!(
|
||||
partner_kg.knows_fact(&FactId("test.fact".to_string())),
|
||||
"partner should know test.fact after surface-tier transfer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_tier_blocks_suspects_fact() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
// Suspects confidence — below KnowsOf threshold for Surface tier
|
||||
let kg_a = make_fact_kg("test.secret", KnowledgeConfidence::Suspects);
|
||||
let kg_b = KnowledgeGraph::new();
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 1,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
assert!(
|
||||
!partner_kg.knows_fact(&FactId("test.secret".to_string())),
|
||||
"Suspects fact should not transfer at Surface tier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_tier_does_not_transfer() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
|
||||
let kg_b = KnowledgeGraph::new();
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -1, // None tier
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
assert!(
|
||||
!partner_kg.knows_fact(&FactId("test.fact".to_string())),
|
||||
"No transfer should occur at None tier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclosure_blocked_fact_never_transfers() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
|
||||
// KnowledgeGraph with disclosure_blocked fact
|
||||
let mut kg_a = KnowledgeGraph::new();
|
||||
kg_a.facts.insert(
|
||||
FactId("secret.blocked".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 1,
|
||||
disclosure_blocked: true, // blocks NPC transfer
|
||||
},
|
||||
);
|
||||
let kg_b = KnowledgeGraph::new();
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 10, // Max trust — still blocked
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
assert!(
|
||||
!partner_kg.knows_fact(&FactId("secret.blocked".to_string())),
|
||||
"disclosure_blocked fact must never transfer regardless of trust"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_cap_applied_on_transfer() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
// KnowsDetails — should be capped to KnowsOf after transfer
|
||||
let kg_a = make_fact_kg("test.detail", KnowledgeConfidence::KnowsDetails);
|
||||
let kg_b = KnowledgeGraph::new();
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 8, // Secret tier
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
let fact = partner_kg
|
||||
.facts
|
||||
.get(&FactId("test.detail".to_string()))
|
||||
.expect("fact should be transferred");
|
||||
assert_eq!(
|
||||
fact.confidence,
|
||||
KnowledgeConfidence::KnowsOf,
|
||||
"transferred confidence must be capped at KnowsOf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_only_never_downgrades_existing_knowledge() {
|
||||
let mut app = build_test_world();
|
||||
|
||||
let sid_a = StableId(1);
|
||||
let sid_b = StableId(2);
|
||||
|
||||
// Speaker knows fact at KnowsOf
|
||||
let kg_a = make_fact_kg("test.fact", KnowledgeConfidence::KnowsOf);
|
||||
|
||||
// Partner already knows fact at KnowsDetails (higher than speaker)
|
||||
let mut kg_b = KnowledgeGraph::new();
|
||||
kg_b.facts.insert(
|
||||
FactId("test.fact".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
let entity_a = spawn_npc(app.world_mut(), sid_a, kg_a);
|
||||
let entity_b = spawn_npc(app.world_mut(), sid_b, kg_b);
|
||||
|
||||
{
|
||||
let mut rel = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel.set_relationship(
|
||||
sid_a,
|
||||
sid_b,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 5,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.world_mut().entity_mut(entity_a).insert(NpcConversation {
|
||||
partner: entity_b,
|
||||
started_tick: 0,
|
||||
end_tick: 100,
|
||||
ticks_since_last_line: 0,
|
||||
});
|
||||
|
||||
app.update();
|
||||
|
||||
let partner_kg = app
|
||||
.world()
|
||||
.entity(entity_b)
|
||||
.get::<KnowledgeGraph>()
|
||||
.expect("partner has KG");
|
||||
let fact = partner_kg
|
||||
.facts
|
||||
.get(&FactId("test.fact".to_string()))
|
||||
.expect("fact exists");
|
||||
assert_eq!(
|
||||
fact.confidence,
|
||||
KnowledgeConfidence::KnowsDetails,
|
||||
"existing higher confidence must not be downgraded"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Point of Interest data model (#148).
|
||||
//!
|
||||
//! POIs are discoverable world locations: quest-relevant places, hidden
|
||||
//! areas, landmarks, vendors, etc. They integrate with the knowledge
|
||||
//! graph via `FactId("poi.*")` namespace per D-079.
|
||||
//!
|
||||
//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted`
|
||||
//! with `Fact` variant to grant POI facts to observers.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::FactId;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Category of point of interest. Determines client-side icon and color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiCategory {
|
||||
/// Named location (dock, bar, office, residential block).
|
||||
Location,
|
||||
/// Vendor or service provider (fixer, medic, data broker).
|
||||
Service,
|
||||
/// Quest-relevant target (drop point, meeting place, evidence site).
|
||||
QuestTarget,
|
||||
/// Hidden area (secret passage, concealed cache, restricted zone).
|
||||
Hidden,
|
||||
/// Navigation landmark visible from a distance.
|
||||
Landmark,
|
||||
}
|
||||
|
||||
/// How a POI was placed in the world (content provenance).
|
||||
///
|
||||
/// Distinct from visibility rules: discovery_source tracks *why* the POI
|
||||
/// exists; visibility tracks *how* it can be found.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PoiDiscoverySource {
|
||||
/// Part of the map template — always present on this map.
|
||||
MapTemplate,
|
||||
/// Procedurally generated at world creation.
|
||||
Procedural,
|
||||
/// Created by a quest or storyline event at runtime.
|
||||
QuestGenerated,
|
||||
/// Revealed by NPC testimony via knowledge grant.
|
||||
NpcRevealed,
|
||||
}
|
||||
|
||||
/// Rules governing when an observer can discover this POI.
|
||||
///
|
||||
/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge
|
||||
/// graph. The discovery system (#149) evaluates these rules each tick
|
||||
/// for POIs not yet known to the observer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PoiVisibility {
|
||||
/// Discoverable when within line of sight (standard LOS rules).
|
||||
LineOfSight,
|
||||
/// Discoverable only within a specific tile range (Manhattan distance).
|
||||
Proximity { range: u32 },
|
||||
/// Not discoverable by observation. Requires a `KnowledgeGranted`
|
||||
/// event from dialogue, evidence, or NPC testimony.
|
||||
KnowledgeOnly,
|
||||
/// Discoverable by LOS, but only if the observer already knows a
|
||||
/// prerequisite fact. Example: a hidden door visible only if the
|
||||
/// observer knows `"quest.secret_passage_hint"`.
|
||||
RequiresFact { fact_id: String },
|
||||
}
|
||||
|
||||
/// Point of Interest ECS component (#148).
|
||||
///
|
||||
/// Attached to world entities that represent discoverable locations.
|
||||
/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added
|
||||
/// to their `KnowledgeGraph` via the discovery system (#149).
|
||||
///
|
||||
/// BTreeMap ordering note: POI entities use `StableEntityId` like all
|
||||
/// other entities. The `poi_id` string is for the fact namespace only.
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointOfInterest {
|
||||
/// Unique identifier within the `poi.*` fact namespace.
|
||||
/// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`.
|
||||
/// Must be unique across all POIs in the world.
|
||||
pub poi_id: String,
|
||||
/// Display name shown to the player after discovery.
|
||||
pub name: String,
|
||||
/// World position of the POI (center tile).
|
||||
pub position: TilePosition,
|
||||
/// Category for client-side rendering (icon, minimap marker).
|
||||
pub category: PoiCategory,
|
||||
/// Content provenance — how this POI was placed in the world.
|
||||
pub discovery_source: PoiDiscoverySource,
|
||||
/// Rules for when/how an observer can discover this POI.
|
||||
pub visibility: PoiVisibility,
|
||||
}
|
||||
|
||||
impl PointOfInterest {
|
||||
/// Generate the `FactId` for this POI in the knowledge graph.
|
||||
/// Format: `"poi.{poi_id}"` per D-079 namespace convention.
|
||||
pub fn fact_id(&self) -> FactId {
|
||||
FactId(format!("poi.{}", self.poi_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest {
|
||||
PointOfInterest {
|
||||
poi_id: id.to_string(),
|
||||
name: format!("Test POI {}", id),
|
||||
position: TilePosition::new(10, 20, 0),
|
||||
category,
|
||||
discovery_source: PoiDiscoverySource::MapTemplate,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_uses_poi_namespace() {
|
||||
let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_id_format_is_deterministic() {
|
||||
let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly);
|
||||
assert_eq!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_poi_ids_produce_different_fact_ids() {
|
||||
let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight);
|
||||
assert_ne!(poi1.fact_id(), poi2.fact_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_visibility_stores_range() {
|
||||
let poi = make_poi(
|
||||
"hidden_cache",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::Proximity { range: 5 },
|
||||
);
|
||||
match poi.visibility {
|
||||
PoiVisibility::Proximity { range } => assert_eq!(range, 5),
|
||||
_ => panic!("Expected Proximity visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_visibility_stores_fact_id() {
|
||||
let poi = make_poi(
|
||||
"secret_door",
|
||||
PoiCategory::Hidden,
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_passage_hint".to_string(),
|
||||
},
|
||||
);
|
||||
match &poi.visibility {
|
||||
PoiVisibility::RequiresFact { fact_id } => {
|
||||
assert_eq!(fact_id, "quest.secret_passage_hint");
|
||||
}
|
||||
_ => panic!("Expected RequiresFact visibility"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_categories_are_distinct() {
|
||||
assert_ne!(PoiCategory::Location, PoiCategory::Service);
|
||||
assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden);
|
||||
assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_discovery_sources_are_distinct() {
|
||||
assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural);
|
||||
assert_ne!(
|
||||
PoiDiscoverySource::QuestGenerated,
|
||||
PoiDiscoverySource::NpcRevealed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_serialization_roundtrip() {
|
||||
let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight);
|
||||
let serialized = serde_yaml::to_string(&poi).expect("serialize");
|
||||
let deserialized: PointOfInterest =
|
||||
serde_yaml::from_str(&serialized).expect("deserialize");
|
||||
assert_eq!(deserialized.poi_id, "med_bay");
|
||||
assert_eq!(deserialized.category, PoiCategory::Service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! POI discovery system (#149).
|
||||
//!
|
||||
//! Detects when the player observer discovers a Point of Interest and
|
||||
//! grants the corresponding `FactId("poi.*")` to their knowledge graph.
|
||||
//!
|
||||
//! Discovery methods handled here:
|
||||
//! - Physical discovery (LOS, proximity) — checked each tick
|
||||
//!
|
||||
//! Discovery methods handled elsewhere:
|
||||
//! - Character background — inserted at spawn time by content system
|
||||
//! - NPC tips / research — via `KnowledgeGranted` event (#546)
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{
|
||||
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
|
||||
};
|
||||
use crate::perception::query::VisibilityGeometry;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::poi::{PoiVisibility, PointOfInterest};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Event emitted when the player discovers a POI.
|
||||
///
|
||||
/// Other systems (monologue, minimap update, storyteller) can react to
|
||||
/// this event. Consumed and cleared each tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoiDiscoveredEvent {
|
||||
/// The `poi_id` string of the discovered POI.
|
||||
pub poi_id: String,
|
||||
/// Display name for monologue/UI use.
|
||||
pub name: String,
|
||||
/// Tick when discovered.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// Resource: queue of POI discovery events from the current tick.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PoiDiscoveryEventQueue {
|
||||
events: Vec<PoiDiscoveredEvent>,
|
||||
}
|
||||
|
||||
impl PoiDiscoveryEventQueue {
|
||||
pub fn push(&mut self, event: PoiDiscoveredEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<PoiDiscoveredEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: check for POI physical discovery by the player observer.
|
||||
///
|
||||
/// Runs after visibility geometry is computed. For each undiscovered POI,
|
||||
/// checks visibility rules against the observer's position and known facts.
|
||||
/// Discovered POIs are added as `FactId("poi.*")` facts to the observer's
|
||||
/// KnowledgeGraph with `DirectObservation` source.
|
||||
pub fn discover_pois(
|
||||
time: Res<SimulationTime>,
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
mut discovery_queue: ResMut<PoiDiscoveryEventQueue>,
|
||||
poi_query: Query<&PointOfInterest>,
|
||||
mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With<PlayerCharacter>>,
|
||||
) {
|
||||
let Ok((observer_pos, mut kg)) = observer_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for poi in poi_query.iter() {
|
||||
let fact_id = poi.fact_id();
|
||||
|
||||
// Skip already-known POIs
|
||||
if kg.knows_fact(&fact_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if can_discover(observer_pos, &geometry, &kg, poi) {
|
||||
// Direct KG write — bypasses the KnowledgeGranted event queue.
|
||||
// Justified for LOS-based physical discovery: the observer sees
|
||||
// the POI directly, no intermediary grant source. This is a D-079
|
||||
// carve-out; NPC tips and research-based POI discovery (Sprint 18)
|
||||
// will use the event queue path.
|
||||
kg.facts.insert(
|
||||
fact_id,
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: time.tick,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
discovery_queue.push(PoiDiscoveredEvent {
|
||||
poi_id: poi.poi_id.clone(),
|
||||
name: poi.name.clone(),
|
||||
tick: time.tick,
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
poi_id = %poi.poi_id,
|
||||
name = %poi.name,
|
||||
tick = time.tick,
|
||||
"Player discovered POI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate whether an observer can discover a POI based on its visibility rules.
|
||||
fn can_discover(
|
||||
observer_pos: &TilePosition,
|
||||
geometry: &VisibilityGeometry,
|
||||
kg: &KnowledgeGraph,
|
||||
poi: &PointOfInterest,
|
||||
) -> bool {
|
||||
match &poi.visibility {
|
||||
PoiVisibility::LineOfSight => {
|
||||
poi.position.z == geometry.observer_z
|
||||
&& geometry
|
||||
.visible_positions
|
||||
.contains(&(poi.position.x, poi.position.y))
|
||||
}
|
||||
PoiVisibility::Proximity { range } => observer_pos
|
||||
.manhattan_distance(&poi.position)
|
||||
.is_some_and(|d| d <= *range),
|
||||
PoiVisibility::KnowledgeOnly => {
|
||||
// Not discoverable by physical observation.
|
||||
// Requires KnowledgeGranted event from dialogue/evidence.
|
||||
false
|
||||
}
|
||||
PoiVisibility::RequiresFact { fact_id } => {
|
||||
// Must know the prerequisite fact AND see the POI in LOS.
|
||||
kg.knows_fact(&FactId(fact_id.clone()))
|
||||
&& poi.position.z == geometry.observer_z
|
||||
&& geometry
|
||||
.visible_positions
|
||||
.contains(&(poi.position.x, poi.position.y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::poi::{PoiCategory, PoiDiscoverySource};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn make_poi(
|
||||
id: &str,
|
||||
position: TilePosition,
|
||||
visibility: PoiVisibility,
|
||||
) -> PointOfInterest {
|
||||
PointOfInterest {
|
||||
poi_id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
position,
|
||||
category: PoiCategory::Location,
|
||||
discovery_source: PoiDiscoverySource::MapTemplate,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry {
|
||||
VisibilityGeometry {
|
||||
visible_tiles: vec![],
|
||||
visible_positions: visible.iter().copied().collect::<BTreeSet<_>>(),
|
||||
sector_lookup: Default::default(),
|
||||
observer_z: z,
|
||||
}
|
||||
}
|
||||
|
||||
// --- can_discover tests ---
|
||||
|
||||
#[test]
|
||||
fn los_poi_discovered_when_in_visible_positions() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(10, 5)], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn los_poi_not_discovered_when_not_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn los_poi_not_discovered_on_different_z() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight);
|
||||
let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_discovered_within_range() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(7, 5, 0),
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// Manhattan distance = 2, range = 3 → discovered
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_not_discovered_outside_range() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// Manhattan distance = 5, range = 3 → not discovered
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_poi_not_discovered_different_z() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"cache",
|
||||
TilePosition::new(5, 6, 1), // different z
|
||||
PoiVisibility::Proximity { range: 3 },
|
||||
);
|
||||
let geometry = make_geometry(&[], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
// manhattan_distance returns None for different z
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_only_never_discovered_physically() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"secret",
|
||||
TilePosition::new(5, 5, 0), // same tile
|
||||
PoiVisibility::KnowledgeOnly,
|
||||
);
|
||||
let geometry = make_geometry(&[(5, 5)], 0);
|
||||
let kg = KnowledgeGraph::new();
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_discovered_when_fact_known_and_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[(8, 5)], 0);
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("quest.secret_hint".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
assert!(can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_not_discovered_without_fact() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[(8, 5)], 0);
|
||||
let kg = KnowledgeGraph::new(); // no facts
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_fact_not_discovered_when_not_visible() {
|
||||
let observer_pos = TilePosition::new(5, 5, 0);
|
||||
let poi = make_poi(
|
||||
"hidden_door",
|
||||
TilePosition::new(8, 5, 0),
|
||||
PoiVisibility::RequiresFact {
|
||||
fact_id: "quest.secret_hint".to_string(),
|
||||
},
|
||||
);
|
||||
let geometry = make_geometry(&[], 0); // not visible
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("quest.secret_hint".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
assert!(!can_discover(&observer_pos, &geometry, &kg, &poi));
|
||||
}
|
||||
|
||||
// --- System integration test ---
|
||||
|
||||
#[test]
|
||||
fn discover_pois_system_grants_fact() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let mut world = World::new();
|
||||
|
||||
// Resources
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 50;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(make_geometry(&[(10, 5)], 0));
|
||||
world.insert_resource(PoiDiscoveryEventQueue::default());
|
||||
|
||||
// Player observer
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
// POI entity
|
||||
world.spawn(make_poi(
|
||||
"docking_bay",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::LineOfSight,
|
||||
));
|
||||
|
||||
// Run system
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(discover_pois);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Verify: player now knows the POI fact
|
||||
let mut query = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
|
||||
let kg = query.single(&world).expect("player should exist");
|
||||
let fact_id = FactId("poi.docking_bay".to_string());
|
||||
assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay");
|
||||
assert_eq!(
|
||||
kg.facts.get(&fact_id).unwrap().confidence,
|
||||
KnowledgeConfidence::KnowsOf
|
||||
);
|
||||
|
||||
// Verify: discovery event was emitted
|
||||
let queue = world.resource::<PoiDiscoveryEventQueue>();
|
||||
assert_eq!(queue.events.len(), 1);
|
||||
assert_eq!(queue.events[0].poi_id, "docking_bay");
|
||||
assert_eq!(queue.events[0].tick, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_pois_system_skips_already_known() {
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
let mut world = World::new();
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(make_geometry(&[(10, 5)], 0));
|
||||
world.insert_resource(PoiDiscoveryEventQueue::default());
|
||||
|
||||
// Player already knows this POI
|
||||
let kg = KnowledgeGraph::with_background(vec![(
|
||||
FactId("poi.docking_bay".to_string()),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: KnowledgeSource::Background,
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
disclosure_blocked: false,
|
||||
},
|
||||
)]);
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
|
||||
|
||||
world.spawn(make_poi(
|
||||
"docking_bay",
|
||||
TilePosition::new(10, 5, 0),
|
||||
PoiVisibility::LineOfSight,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(discover_pois);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// No new events — already known
|
||||
let queue = world.resource::<PoiDiscoveryEventQueue>();
|
||||
assert!(queue.is_empty(), "No discovery event for already-known POI");
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ fn verify_template_role_slots() {
|
||||
fn spawn_npc_from_content_store() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
|
||||
// Create a minimal content store with one test NPC
|
||||
let mut store = ContentStore::default();
|
||||
@@ -333,6 +334,7 @@ fn spawn_real_content_with_relationships_and_secrets() {
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
|
||||
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
|
||||
|
||||
let store = load_content(&root).expect("content loading should succeed");
|
||||
|
||||
@@ -1364,6 +1364,229 @@ fn v10_payload_deserializes_into_v11_struct() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #232: Protocol versioning scheme tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A snapshot serialized without newer optional fields (simulating an older server)
|
||||
/// must deserialize with serde defaults — core migration pattern (#232).
|
||||
///
|
||||
/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify
|
||||
/// they fill in as their zero/None values on deserialization.
|
||||
#[test]
|
||||
fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
// JSON with only the required fields (simulating a minimal old snapshot).
|
||||
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
|
||||
// are all `#[serde(default)]` — they must default to None/empty when absent.
|
||||
let minimal_json = serde_json::json!({
|
||||
"version": 13,
|
||||
"tick": 42,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": 0,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full"
|
||||
},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": [{
|
||||
"entity_id": 1,
|
||||
"x": 5.0,
|
||||
"y": 5.0,
|
||||
"z": 0,
|
||||
"kind": "Npc",
|
||||
"visibility": "Forward",
|
||||
"relationship": "Unknown",
|
||||
"observation": "Visible"
|
||||
// "tell_state" intentionally absent
|
||||
}],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
"current_monologue": null,
|
||||
"pending_recognitions": [],
|
||||
"dialogue_response": null,
|
||||
"blocked_entities": [],
|
||||
"scan_events": [],
|
||||
"sound_events": [],
|
||||
"conversation_events": [],
|
||||
"conversation_ended": [],
|
||||
"follow_state": null,
|
||||
"rng_seed": null
|
||||
});
|
||||
|
||||
let decoded: ObserverSnapshot =
|
||||
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
|
||||
|
||||
// Version and required fields present
|
||||
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
|
||||
// `#[serde(default, skip_serializing_if = "Option::is_none")]` field
|
||||
// defaults to None when absent from the wire
|
||||
assert_eq!(
|
||||
decoded.entities[0].tell_state, None,
|
||||
"tell_state must default to None when absent from wire"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.rng_seed, None,
|
||||
"rng_seed must default to None when absent from wire"
|
||||
);
|
||||
assert!(
|
||||
decoded.follow_state.is_none(),
|
||||
"follow_state must default to None when absent from wire"
|
||||
);
|
||||
}
|
||||
|
||||
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
|
||||
/// the version field after deserialization (#232 compatibility checking).
|
||||
#[test]
|
||||
fn snapshot_version_mismatch_is_detectable() {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
let future_version: u8 = PROTOCOL_VERSION + 1;
|
||||
snapshot.version = future_version;
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
// The version field faithfully preserves the value — caller detects mismatch
|
||||
assert_eq!(
|
||||
decoded.version, future_version,
|
||||
"version field must survive round-trip unchanged"
|
||||
);
|
||||
assert_ne!(
|
||||
decoded.version, PROTOCOL_VERSION,
|
||||
"client should detect this as a version mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
|
||||
/// A snapshot with tell_state=None produces fewer bytes than one with
|
||||
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
|
||||
#[test]
|
||||
fn tell_state_none_is_omitted_from_wire() {
|
||||
let entity_no_tell = VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
tell_state: None,
|
||||
};
|
||||
let entity_with_tell = VisibleEntity {
|
||||
tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous),
|
||||
..entity_no_tell.clone()
|
||||
};
|
||||
|
||||
let bytes_no_tell =
|
||||
rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state");
|
||||
let bytes_with_tell =
|
||||
rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state");
|
||||
|
||||
assert!(
|
||||
bytes_no_tell.len() < bytes_with_tell.len(),
|
||||
"tell_state=None should produce fewer bytes (skip_serializing_if contract)"
|
||||
);
|
||||
}
|
||||
|
||||
/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity.
|
||||
/// Closing coverage gap for v13 tell_state field (#90, D-024).
|
||||
#[test]
|
||||
fn all_tell_category_variants_roundtrip() {
|
||||
use settled_reach_server::npc::tell_state::TellCategory;
|
||||
|
||||
let categories = [
|
||||
TellCategory::Nervous,
|
||||
TellCategory::Angry,
|
||||
TellCategory::Friendly,
|
||||
TellCategory::Guarded,
|
||||
TellCategory::RoutineDeviation,
|
||||
];
|
||||
|
||||
for category in categories {
|
||||
let entity = VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 3.0,
|
||||
y: 4.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
tell_state: Some(category),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&entity).expect("serialize");
|
||||
let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(
|
||||
decoded.tell_state,
|
||||
Some(category),
|
||||
"TellCategory::{:?} did not survive round-trip",
|
||||
category
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232).
|
||||
/// Closes a coverage gap — not all VerbKind variants were previously verified.
|
||||
#[test]
|
||||
fn all_verb_kind_variants_roundtrip_v232() {
|
||||
let all_verbs = [
|
||||
VerbKind::ExamineNpc,
|
||||
VerbKind::Talk,
|
||||
VerbKind::Observe,
|
||||
VerbKind::Read,
|
||||
VerbKind::Open,
|
||||
VerbKind::Close,
|
||||
VerbKind::Search,
|
||||
VerbKind::Use,
|
||||
VerbKind::Take,
|
||||
VerbKind::Sit,
|
||||
VerbKind::Follow,
|
||||
VerbKind::Confront,
|
||||
VerbKind::ExamineObject,
|
||||
];
|
||||
|
||||
for kind in all_verbs {
|
||||
let interaction = NearbyInteraction {
|
||||
entity_id: 1,
|
||||
entity_type: EntityKind::Npc,
|
||||
distance: 1,
|
||||
verbs: vec![VerbOption {
|
||||
kind,
|
||||
label: "Test".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
}],
|
||||
object_type: None,
|
||||
contradicted: false,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize");
|
||||
let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(
|
||||
decoded.verbs[0].kind, kind,
|
||||
"VerbKind::{:?} did not survive round-trip",
|
||||
kind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
|
||||
/// This guards against accidental widening of the version type.
|
||||
#[test]
|
||||
fn protocol_version_fits_in_u8() {
|
||||
// u8 max is 255 — enough for ~242 more protocol iterations.
|
||||
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
|
||||
assert!(
|
||||
PROTOCOL_VERSION <= 200,
|
||||
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
|
||||
PROTOCOL_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post a comment to a Gitea PR or issue.
|
||||
# Usage: tea-comment <number> "comment body"
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: tea-comment <number> <comment>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NUMBER="$1"
|
||||
BODY="$2"
|
||||
HASH=$(echo -n "$BODY" | md5sum | cut -c1-8)
|
||||
TMPFILE="/tmp/tea-comment-${NUMBER}-${HASH}.md"
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
printf '%s' "$BODY" > "$TMPFILE"
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach "$NUMBER" "$(cat "$TMPFILE")"
|
||||
Reference in New Issue
Block a user