chore(meta): resolve CHANGELOG conflict — merge main into visual

This commit is contained in:
2026-02-25 02:04:45 +01:00
58 changed files with 7172 additions and 235 deletions
+16
View File
@@ -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.
+8
View File
@@ -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)
+40
View File
@@ -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
```
+45
View File
@@ -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
```
+33
View File
@@ -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.
+1
View File
@@ -44,6 +44,7 @@
"Bash(make)",
"Bash(tea *)",
"Bash(tooling/tea-comment *)",
"Bash(chmod *)",
"Bash(ls *)",
+3 -3
View File
@@ -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
+2 -4
View File
@@ -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.
+1 -5
View File
@@ -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 -15
View File
@@ -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
+31 -6
View File
@@ -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"
+2 -3
View File
@@ -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
View File
@@ -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-079D-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
+26 -141
View File
@@ -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)
+5 -1
View File
@@ -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")]
+5
View File
@@ -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)
+6 -1
View File
@@ -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()
+298
View File
@@ -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}],
}]
+318
View File
@@ -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")
+26 -1
View File
@@ -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
+97
View File
@@ -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)
+22
View File
@@ -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")
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+3 -9
View File
@@ -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.
+99
View File
@@ -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
```
+90
View File
@@ -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
```
+88
View File
@@ -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 |
+175
View File
@@ -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
```
+5 -1
View File
@@ -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)
+6
View File
@@ -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);
+9
View File
@@ -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
}
+26 -3
View File
@@ -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)]
+86
View File
@@ -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);
}
}
+390 -1
View File
@@ -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");
}
}
+429 -6
View File
@@ -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
);
}
}
+13 -1
View File
@@ -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),
+56
View File
@@ -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 ---
+696
View File
@@ -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"
);
}
}
+9
View File
@@ -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
+206 -4
View File
@@ -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)"
);
}
}
+556
View File
@@ -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"
);
}
}
+1
View File
@@ -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>();
+3
View File
@@ -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,
},
);
+2
View File
@@ -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,
},
);
+124 -2
View File
@@ -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);
+17
View File
@@ -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),
),
);
+411 -1
View File
@@ -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 (13 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 13 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"
);
}
}
+191
View File
@@ -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);
}
}
+426
View File
@@ -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");
}
}
+2
View File
@@ -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");
+223
View File
@@ -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]
+18
View File
@@ -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")"