From 8734e7d835eff04af418118df520bc513a31fef6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:27:50 +0100 Subject: [PATCH 01/21] chore(skills): add push-pr skill for safe PR lifecycle Pushes current branch and creates or updates a PR without ever merging into main. Prevents accidental PR merges by restricting the skill to branch-side operations only. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/push-pr/SKILL.md | 109 ++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .claude/skills/push-pr/SKILL.md diff --git a/.claude/skills/push-pr/SKILL.md b/.claude/skills/push-pr/SKILL.md new file mode 100644 index 000000000..7413ac4f2 --- /dev/null +++ b/.claude/skills/push-pr/SKILL.md @@ -0,0 +1,109 @@ +--- +name: push-pr +description: > + Push commits and create or update a pull request. Use when the user says + "push pr", "push and create pr", "update pr", "create a pr", "open a pr", + or invokes /push-pr. NOT triggered by plain "push" (that's just git push). + Pushes the current branch, creates a PR if none exists, or confirms the + existing PR was updated. NEVER merges the PR into main — this skill only + pushes to the branch and manages the PR lifecycle. +user-invocable: true +allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion +--- + +# Push PR Skill + +Push commits to remote and create or update a PR. Operates exclusively on the +current branch — never touches main. + +## Safety Rules (NON-NEGOTIABLE) + +- **NEVER merge a PR into main.** No `tea pr merge`, no `git merge` into main. +- **NEVER checkout or push to main.** +- **NEVER force-push** unless the user explicitly requests it. +- **NEVER use `--no-verify` or skip hooks.** +- Only push to the current working branch. + +## Workflow + +### 1. Validate branch + +```bash +git branch --show-current +``` + +If on `main`, stop: "You're on main. Switch to a team branch first." + +### 2. Check for unpushed commits + +```bash +git fetch --all +git status +git log --oneline origin/.. +``` + +If no unpushed commits, skip to step 4 (PR check). + +### 3. Check for conflicts with main + +```bash +git merge-tree --write-tree origin/main HEAD 2>&1 +``` + +If conflicts reported, merge main into current branch: + +```bash +git merge origin/main --no-edit +``` + +If merge conflicts, **stop and report** — let the user resolve. +If clean, continue. + +### 4. Push + +```bash +git push origin +``` + +If push fails, stop and report. Never force-push without explicit request. + +### 5. Check for existing PR + +```bash +tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple +``` + +Match current branch name in PR list. + +- **PR exists**: Report "Pushed N commits to ``. PR #X updated." Done. +- **No PR**: Continue to step 6. + +### 6. Create a new PR + +```bash +git log --oneline main.. +git diff --stat main... +``` + +Draft title (`(): `, max 70 chars) and description. + +```bash +cat > /tmp/pr-body.md << 'EOF' +## Summary +... +EOF +tea pr create \ + --repo jpmschweitzer/settled-reach \ + --login schweitz \ + --title "" \ + --description "$(cat /tmp/pr-body.md)" \ + --base main \ + --head <branch> +``` + +Report PR URL when done. + +## Arguments + +If the user passes arguments (e.g., `/push-pr "my title"`), use them as the +PR title instead of generating one. From 10f5be29b1acae55e71a2baab0747a0920f8be1e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 19:48:13 +0100 Subject: [PATCH 02/21] feat(data): create content/ directory skeleton Scaffolds the full content directory structure per D-057 spec: district-as-atomic-pack layout with Sova Transit as the first district. Includes 17 NPC stubs, 3 locations, 5 triangles, 9 dialogue pools, 8 monologue pools, routines, 7 factions, 7 knowledge catalogs, 8 enum definitions, and global region. Implements ticket #385. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- content/_meta/README.md | 6 ++++++ content/content.yaml | 7 +++++++ .../maintenance-corridors/ring-operative.yaml | 1 + .../dialogue/the-last-shift/bar-owner.yaml | 1 + .../dialogue/the-last-shift/bar-regular.yaml | 1 + .../dialogue/the-last-shift/bartender.yaml | 1 + .../dialogue/the-terminal/courier.yaml | 1 + .../dialogue/the-terminal/dock-worker.yaml | 1 + .../dialogue/the-terminal/new-hire.yaml | 1 + .../dialogue/the-terminal/scheduler.yaml | 1 + .../dialogue/the-terminal/shift-supervisor.yaml | 1 + content/districts/sova-transit/district.yaml | 15 +++++++++++++++ .../locations/maintenance-corridors.yaml | 2 ++ .../sova-transit/locations/the-last-shift.yaml | 2 ++ .../sova-transit/locations/the-terminal.yaml | 2 ++ .../sova-transit/monologue/detective/general.yaml | 1 + .../detective/maintenance-corridors.yaml | 1 + .../monologue/detective/the-last-shift.yaml | 1 + .../monologue/detective/the-terminal.yaml | 1 + .../sova-transit/monologue/smuggler/general.yaml | 1 + .../monologue/smuggler/maintenance-corridors.yaml | 1 + .../monologue/smuggler/the-last-shift.yaml | 1 + .../monologue/smuggler/the-terminal.yaml | 1 + content/districts/sova-transit/npcs/devra.yaml | 2 ++ content/districts/sova-transit/npcs/drin.yaml | 2 ++ content/districts/sova-transit/npcs/harek.yaml | 2 ++ .../districts/sova-transit/npcs/kael-davan.yaml | 2 ++ .../districts/sova-transit/npcs/lera-sessik.yaml | 2 ++ .../districts/sova-transit/npcs/maret-korr.yaml | 2 ++ .../districts/sova-transit/npcs/naia-tamm.yaml | 2 ++ content/districts/sova-transit/npcs/olin.yaml | 2 ++ content/districts/sova-transit/npcs/pell.yaml | 2 ++ content/districts/sova-transit/npcs/renn.yaml | 2 ++ content/districts/sova-transit/npcs/resha.yaml | 2 ++ content/districts/sova-transit/npcs/sabel.yaml | 2 ++ .../districts/sova-transit/npcs/sera-venn.yaml | 2 ++ content/districts/sova-transit/npcs/sess.yaml | 2 ++ content/districts/sova-transit/npcs/tav.yaml | 2 ++ .../districts/sova-transit/npcs/torek-lintar.yaml | 2 ++ content/districts/sova-transit/npcs/voss.yaml | 2 ++ .../sova-transit/routines/schedules.yaml | 2 ++ content/districts/sova-transit/templates/.gitkeep | 0 .../sova-transit/triangles/bar-tensions.yaml | 1 + .../sova-transit/triangles/hub-power.yaml | 1 + .../triangles/informant-question.yaml | 1 + .../sova-transit/triangles/worried-knowledge.yaml | 1 + .../sova-transit/triangles/worried-partner.yaml | 1 + content/global/contraband/.gitkeep | 0 content/global/enums/access-tiers.yaml | 1 + content/global/enums/moods.yaml | 1 + content/global/enums/motivations.yaml | 1 + content/global/enums/patterns.yaml | 1 + content/global/enums/situations.yaml | 1 + content/global/enums/topics.yaml | 1 + content/global/enums/triggers.yaml | 1 + content/global/enums/trust-tiers.yaml | 1 + content/global/factions/concord-assembly.yaml | 1 + .../global/factions/guardians-of-autonomy.yaml | 1 + content/global/factions/lattice-commission.yaml | 1 + content/global/factions/syndics.yaml | 1 + content/global/factions/the-ring.yaml | 1 + content/global/factions/the-unbound.yaml | 1 + content/global/factions/veil-institute.yaml | 1 + content/global/knowledge/contraband.yaml | 1 + content/global/knowledge/entity-attributes.yaml | 1 + content/global/knowledge/investigation.yaml | 1 + content/global/knowledge/location.yaml | 1 + content/global/knowledge/progress.yaml | 1 + content/global/knowledge/relationship.yaml | 1 + content/global/knowledge/world.yaml | 1 + content/global/regions/krenn.yaml | 1 + content/global/technology/.gitkeep | 0 72 files changed, 115 insertions(+) create mode 100644 content/_meta/README.md create mode 100644 content/content.yaml create mode 100644 content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml create mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml create mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml create mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml create mode 100644 content/districts/sova-transit/dialogue/the-terminal/courier.yaml create mode 100644 content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml create mode 100644 content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml create mode 100644 content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml create mode 100644 content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml create mode 100644 content/districts/sova-transit/district.yaml create mode 100644 content/districts/sova-transit/locations/maintenance-corridors.yaml create mode 100644 content/districts/sova-transit/locations/the-last-shift.yaml create mode 100644 content/districts/sova-transit/locations/the-terminal.yaml create mode 100644 content/districts/sova-transit/monologue/detective/general.yaml create mode 100644 content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml create mode 100644 content/districts/sova-transit/monologue/detective/the-last-shift.yaml create mode 100644 content/districts/sova-transit/monologue/detective/the-terminal.yaml create mode 100644 content/districts/sova-transit/monologue/smuggler/general.yaml create mode 100644 content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml create mode 100644 content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml create mode 100644 content/districts/sova-transit/monologue/smuggler/the-terminal.yaml create mode 100644 content/districts/sova-transit/npcs/devra.yaml create mode 100644 content/districts/sova-transit/npcs/drin.yaml create mode 100644 content/districts/sova-transit/npcs/harek.yaml create mode 100644 content/districts/sova-transit/npcs/kael-davan.yaml create mode 100644 content/districts/sova-transit/npcs/lera-sessik.yaml create mode 100644 content/districts/sova-transit/npcs/maret-korr.yaml create mode 100644 content/districts/sova-transit/npcs/naia-tamm.yaml create mode 100644 content/districts/sova-transit/npcs/olin.yaml create mode 100644 content/districts/sova-transit/npcs/pell.yaml create mode 100644 content/districts/sova-transit/npcs/renn.yaml create mode 100644 content/districts/sova-transit/npcs/resha.yaml create mode 100644 content/districts/sova-transit/npcs/sabel.yaml create mode 100644 content/districts/sova-transit/npcs/sera-venn.yaml create mode 100644 content/districts/sova-transit/npcs/sess.yaml create mode 100644 content/districts/sova-transit/npcs/tav.yaml create mode 100644 content/districts/sova-transit/npcs/torek-lintar.yaml create mode 100644 content/districts/sova-transit/npcs/voss.yaml create mode 100644 content/districts/sova-transit/routines/schedules.yaml create mode 100644 content/districts/sova-transit/templates/.gitkeep create mode 100644 content/districts/sova-transit/triangles/bar-tensions.yaml create mode 100644 content/districts/sova-transit/triangles/hub-power.yaml create mode 100644 content/districts/sova-transit/triangles/informant-question.yaml create mode 100644 content/districts/sova-transit/triangles/worried-knowledge.yaml create mode 100644 content/districts/sova-transit/triangles/worried-partner.yaml create mode 100644 content/global/contraband/.gitkeep create mode 100644 content/global/enums/access-tiers.yaml create mode 100644 content/global/enums/moods.yaml create mode 100644 content/global/enums/motivations.yaml create mode 100644 content/global/enums/patterns.yaml create mode 100644 content/global/enums/situations.yaml create mode 100644 content/global/enums/topics.yaml create mode 100644 content/global/enums/triggers.yaml create mode 100644 content/global/enums/trust-tiers.yaml create mode 100644 content/global/factions/concord-assembly.yaml create mode 100644 content/global/factions/guardians-of-autonomy.yaml create mode 100644 content/global/factions/lattice-commission.yaml create mode 100644 content/global/factions/syndics.yaml create mode 100644 content/global/factions/the-ring.yaml create mode 100644 content/global/factions/the-unbound.yaml create mode 100644 content/global/factions/veil-institute.yaml create mode 100644 content/global/knowledge/contraband.yaml create mode 100644 content/global/knowledge/entity-attributes.yaml create mode 100644 content/global/knowledge/investigation.yaml create mode 100644 content/global/knowledge/location.yaml create mode 100644 content/global/knowledge/progress.yaml create mode 100644 content/global/knowledge/relationship.yaml create mode 100644 content/global/knowledge/world.yaml create mode 100644 content/global/regions/krenn.yaml create mode 100644 content/global/technology/.gitkeep diff --git a/content/_meta/README.md b/content/_meta/README.md new file mode 100644 index 000000000..0800a891a --- /dev/null +++ b/content/_meta/README.md @@ -0,0 +1,6 @@ +# Content Infrastructure + +Directories prefixed with `_` are infrastructure, not game content. The server content loader skips directories starting with `_` when scanning for content files. + +- `_meta/` — Infrastructure metadata (this directory) +- `_schema/` — JSON Schema validation files (draft 2020-12) diff --git a/content/content.yaml b/content/content.yaml new file mode 100644 index 000000000..a62b6031c --- /dev/null +++ b/content/content.yaml @@ -0,0 +1,7 @@ +# Content manifest — The Settled Reach v0.1 +# The server reads this first to discover enabled districts and load order. +version: "0.1.0" +districts: + - id: "sova-transit" + path: "districts/sova-transit" + enabled: true diff --git a/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml b/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml new file mode 100644 index 000000000..a5cc92125 --- /dev/null +++ b/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml @@ -0,0 +1 @@ +# Dialogue: ring-operative at Maintenance Corridors diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml new file mode 100644 index 000000000..565ae4882 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml @@ -0,0 +1 @@ +# Dialogue: bar-owner at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml new file mode 100644 index 000000000..6f99a3176 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml @@ -0,0 +1 @@ +# Dialogue: bar-regular at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml new file mode 100644 index 000000000..27b53f4a7 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml @@ -0,0 +1 @@ +# Dialogue: bartender at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-terminal/courier.yaml b/content/districts/sova-transit/dialogue/the-terminal/courier.yaml new file mode 100644 index 000000000..8fdc9b9c7 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-terminal/courier.yaml @@ -0,0 +1 @@ +# Dialogue: courier at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml b/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml new file mode 100644 index 000000000..66a6d81fe --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml @@ -0,0 +1 @@ +# Dialogue: dock-worker at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml b/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml new file mode 100644 index 000000000..4390a85c5 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml @@ -0,0 +1 @@ +# Dialogue: new-hire at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml b/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml new file mode 100644 index 000000000..54dd25883 --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml @@ -0,0 +1 @@ +# Dialogue: scheduler at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml b/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml new file mode 100644 index 000000000..1247ffcdd --- /dev/null +++ b/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml @@ -0,0 +1 @@ +# Dialogue: shift-supervisor at The Terminal diff --git a/content/districts/sova-transit/district.yaml b/content/districts/sova-transit/district.yaml new file mode 100644 index 000000000..a56bd83b4 --- /dev/null +++ b/content/districts/sova-transit/district.yaml @@ -0,0 +1,15 @@ +# Sova Transit District metadata (D-036) +canonical_id: "krenn.sova.transit" +display_name: "Sova Transit District" +system: "krenn" +station: "sova" +district: "transit" +description: > + A 40-year-old prefab-modular-retrofitted freight logistics hub on Station Sova. + Three social sites: The Terminal (logistics hub), The Last Shift (bar), + and maintenance corridors. +locations: + - "the-terminal" + - "the-last-shift" + - "maintenance-corridors" +npc_count: 17 diff --git a/content/districts/sova-transit/locations/maintenance-corridors.yaml b/content/districts/sova-transit/locations/maintenance-corridors.yaml new file mode 100644 index 000000000..74037a133 --- /dev/null +++ b/content/districts/sova-transit/locations/maintenance-corridors.yaml @@ -0,0 +1,2 @@ +# Location: Maintenance Corridors (smuggling spaces) +# canonical_id: krenn.sova.transit.location.maintenance-corridors diff --git a/content/districts/sova-transit/locations/the-last-shift.yaml b/content/districts/sova-transit/locations/the-last-shift.yaml new file mode 100644 index 000000000..cf07df16d --- /dev/null +++ b/content/districts/sova-transit/locations/the-last-shift.yaml @@ -0,0 +1,2 @@ +# Location: The Last Shift (bar) +# canonical_id: krenn.sova.transit.location.the-last-shift diff --git a/content/districts/sova-transit/locations/the-terminal.yaml b/content/districts/sova-transit/locations/the-terminal.yaml new file mode 100644 index 000000000..7ea006283 --- /dev/null +++ b/content/districts/sova-transit/locations/the-terminal.yaml @@ -0,0 +1,2 @@ +# Location: The Terminal (logistics hub) +# canonical_id: krenn.sova.transit.location.the-terminal diff --git a/content/districts/sova-transit/monologue/detective/general.yaml b/content/districts/sova-transit/monologue/detective/general.yaml new file mode 100644 index 000000000..a1330ca50 --- /dev/null +++ b/content/districts/sova-transit/monologue/detective/general.yaml @@ -0,0 +1 @@ +# Monologue: detective at general diff --git a/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml b/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml new file mode 100644 index 000000000..ce27cb434 --- /dev/null +++ b/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml @@ -0,0 +1 @@ +# Monologue: detective at maintenance-corridors diff --git a/content/districts/sova-transit/monologue/detective/the-last-shift.yaml b/content/districts/sova-transit/monologue/detective/the-last-shift.yaml new file mode 100644 index 000000000..2f422b2e6 --- /dev/null +++ b/content/districts/sova-transit/monologue/detective/the-last-shift.yaml @@ -0,0 +1 @@ +# Monologue: detective at the-last-shift diff --git a/content/districts/sova-transit/monologue/detective/the-terminal.yaml b/content/districts/sova-transit/monologue/detective/the-terminal.yaml new file mode 100644 index 000000000..afbc4c886 --- /dev/null +++ b/content/districts/sova-transit/monologue/detective/the-terminal.yaml @@ -0,0 +1 @@ +# Monologue: detective at the-terminal diff --git a/content/districts/sova-transit/monologue/smuggler/general.yaml b/content/districts/sova-transit/monologue/smuggler/general.yaml new file mode 100644 index 000000000..786bbd0aa --- /dev/null +++ b/content/districts/sova-transit/monologue/smuggler/general.yaml @@ -0,0 +1 @@ +# Monologue: smuggler at general diff --git a/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml b/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml new file mode 100644 index 000000000..5ee5f5e34 --- /dev/null +++ b/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml @@ -0,0 +1 @@ +# Monologue: smuggler at maintenance-corridors diff --git a/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml b/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml new file mode 100644 index 000000000..ce327e9fc --- /dev/null +++ b/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml @@ -0,0 +1 @@ +# Monologue: smuggler at the-last-shift diff --git a/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml b/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml new file mode 100644 index 000000000..68e377e93 --- /dev/null +++ b/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml @@ -0,0 +1 @@ +# Monologue: smuggler at the-terminal diff --git a/content/districts/sova-transit/npcs/devra.yaml b/content/districts/sova-transit/npcs/devra.yaml new file mode 100644 index 000000000..846fa92af --- /dev/null +++ b/content/districts/sova-transit/npcs/devra.yaml @@ -0,0 +1,2 @@ +# NPC Profile: devra +# canonical_id: krenn.sova.transit.npc.devra diff --git a/content/districts/sova-transit/npcs/drin.yaml b/content/districts/sova-transit/npcs/drin.yaml new file mode 100644 index 000000000..cff9246ae --- /dev/null +++ b/content/districts/sova-transit/npcs/drin.yaml @@ -0,0 +1,2 @@ +# NPC Profile: drin +# canonical_id: krenn.sova.transit.npc.drin diff --git a/content/districts/sova-transit/npcs/harek.yaml b/content/districts/sova-transit/npcs/harek.yaml new file mode 100644 index 000000000..7514edd0b --- /dev/null +++ b/content/districts/sova-transit/npcs/harek.yaml @@ -0,0 +1,2 @@ +# NPC Profile: harek +# canonical_id: krenn.sova.transit.npc.harek diff --git a/content/districts/sova-transit/npcs/kael-davan.yaml b/content/districts/sova-transit/npcs/kael-davan.yaml new file mode 100644 index 000000000..afcf96b4c --- /dev/null +++ b/content/districts/sova-transit/npcs/kael-davan.yaml @@ -0,0 +1,2 @@ +# NPC Profile: kael-davan +# canonical_id: krenn.sova.transit.npc.kael-davan diff --git a/content/districts/sova-transit/npcs/lera-sessik.yaml b/content/districts/sova-transit/npcs/lera-sessik.yaml new file mode 100644 index 000000000..22c4bc44e --- /dev/null +++ b/content/districts/sova-transit/npcs/lera-sessik.yaml @@ -0,0 +1,2 @@ +# NPC Profile: lera-sessik +# canonical_id: krenn.sova.transit.npc.lera-sessik diff --git a/content/districts/sova-transit/npcs/maret-korr.yaml b/content/districts/sova-transit/npcs/maret-korr.yaml new file mode 100644 index 000000000..6f05c5f54 --- /dev/null +++ b/content/districts/sova-transit/npcs/maret-korr.yaml @@ -0,0 +1,2 @@ +# NPC Profile: maret-korr +# canonical_id: krenn.sova.transit.npc.maret-korr diff --git a/content/districts/sova-transit/npcs/naia-tamm.yaml b/content/districts/sova-transit/npcs/naia-tamm.yaml new file mode 100644 index 000000000..7e4a8e1c6 --- /dev/null +++ b/content/districts/sova-transit/npcs/naia-tamm.yaml @@ -0,0 +1,2 @@ +# NPC Profile: naia-tamm +# canonical_id: krenn.sova.transit.npc.naia-tamm diff --git a/content/districts/sova-transit/npcs/olin.yaml b/content/districts/sova-transit/npcs/olin.yaml new file mode 100644 index 000000000..ac9662342 --- /dev/null +++ b/content/districts/sova-transit/npcs/olin.yaml @@ -0,0 +1,2 @@ +# NPC Profile: olin +# canonical_id: krenn.sova.transit.npc.olin diff --git a/content/districts/sova-transit/npcs/pell.yaml b/content/districts/sova-transit/npcs/pell.yaml new file mode 100644 index 000000000..18c727a2c --- /dev/null +++ b/content/districts/sova-transit/npcs/pell.yaml @@ -0,0 +1,2 @@ +# NPC Profile: pell +# canonical_id: krenn.sova.transit.npc.pell diff --git a/content/districts/sova-transit/npcs/renn.yaml b/content/districts/sova-transit/npcs/renn.yaml new file mode 100644 index 000000000..6ab7c95d2 --- /dev/null +++ b/content/districts/sova-transit/npcs/renn.yaml @@ -0,0 +1,2 @@ +# NPC Profile: renn +# canonical_id: krenn.sova.transit.npc.renn diff --git a/content/districts/sova-transit/npcs/resha.yaml b/content/districts/sova-transit/npcs/resha.yaml new file mode 100644 index 000000000..5b088b58e --- /dev/null +++ b/content/districts/sova-transit/npcs/resha.yaml @@ -0,0 +1,2 @@ +# NPC Profile: resha +# canonical_id: krenn.sova.transit.npc.resha diff --git a/content/districts/sova-transit/npcs/sabel.yaml b/content/districts/sova-transit/npcs/sabel.yaml new file mode 100644 index 000000000..7b7e0dccd --- /dev/null +++ b/content/districts/sova-transit/npcs/sabel.yaml @@ -0,0 +1,2 @@ +# NPC Profile: sabel +# canonical_id: krenn.sova.transit.npc.sabel diff --git a/content/districts/sova-transit/npcs/sera-venn.yaml b/content/districts/sova-transit/npcs/sera-venn.yaml new file mode 100644 index 000000000..2c264100c --- /dev/null +++ b/content/districts/sova-transit/npcs/sera-venn.yaml @@ -0,0 +1,2 @@ +# NPC Profile: sera-venn +# canonical_id: krenn.sova.transit.npc.sera-venn diff --git a/content/districts/sova-transit/npcs/sess.yaml b/content/districts/sova-transit/npcs/sess.yaml new file mode 100644 index 000000000..f7a83ac28 --- /dev/null +++ b/content/districts/sova-transit/npcs/sess.yaml @@ -0,0 +1,2 @@ +# NPC Profile: sess +# canonical_id: krenn.sova.transit.npc.sess diff --git a/content/districts/sova-transit/npcs/tav.yaml b/content/districts/sova-transit/npcs/tav.yaml new file mode 100644 index 000000000..871ec7d40 --- /dev/null +++ b/content/districts/sova-transit/npcs/tav.yaml @@ -0,0 +1,2 @@ +# NPC Profile: tav +# canonical_id: krenn.sova.transit.npc.tav diff --git a/content/districts/sova-transit/npcs/torek-lintar.yaml b/content/districts/sova-transit/npcs/torek-lintar.yaml new file mode 100644 index 000000000..43f865e97 --- /dev/null +++ b/content/districts/sova-transit/npcs/torek-lintar.yaml @@ -0,0 +1,2 @@ +# NPC Profile: torek-lintar +# canonical_id: krenn.sova.transit.npc.torek-lintar diff --git a/content/districts/sova-transit/npcs/voss.yaml b/content/districts/sova-transit/npcs/voss.yaml new file mode 100644 index 000000000..cfdad929c --- /dev/null +++ b/content/districts/sova-transit/npcs/voss.yaml @@ -0,0 +1,2 @@ +# NPC Profile: voss +# canonical_id: krenn.sova.transit.npc.voss diff --git a/content/districts/sova-transit/routines/schedules.yaml b/content/districts/sova-transit/routines/schedules.yaml new file mode 100644 index 000000000..b59e9f585 --- /dev/null +++ b/content/districts/sova-transit/routines/schedules.yaml @@ -0,0 +1,2 @@ +# NPC daily routine schedules — Sova Transit District +# All NPC schedules in one file for cross-NPC scheduling validation diff --git a/content/districts/sova-transit/templates/.gitkeep b/content/districts/sova-transit/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/content/districts/sova-transit/triangles/bar-tensions.yaml b/content/districts/sova-transit/triangles/bar-tensions.yaml new file mode 100644 index 000000000..230477d94 --- /dev/null +++ b/content/districts/sova-transit/triangles/bar-tensions.yaml @@ -0,0 +1 @@ +# Triangle: bar-tensions diff --git a/content/districts/sova-transit/triangles/hub-power.yaml b/content/districts/sova-transit/triangles/hub-power.yaml new file mode 100644 index 000000000..e6b04973e --- /dev/null +++ b/content/districts/sova-transit/triangles/hub-power.yaml @@ -0,0 +1 @@ +# Triangle: hub-power diff --git a/content/districts/sova-transit/triangles/informant-question.yaml b/content/districts/sova-transit/triangles/informant-question.yaml new file mode 100644 index 000000000..6f074c355 --- /dev/null +++ b/content/districts/sova-transit/triangles/informant-question.yaml @@ -0,0 +1 @@ +# Triangle: informant-question diff --git a/content/districts/sova-transit/triangles/worried-knowledge.yaml b/content/districts/sova-transit/triangles/worried-knowledge.yaml new file mode 100644 index 000000000..107e0a5c9 --- /dev/null +++ b/content/districts/sova-transit/triangles/worried-knowledge.yaml @@ -0,0 +1 @@ +# Triangle: worried-knowledge diff --git a/content/districts/sova-transit/triangles/worried-partner.yaml b/content/districts/sova-transit/triangles/worried-partner.yaml new file mode 100644 index 000000000..de7748a3e --- /dev/null +++ b/content/districts/sova-transit/triangles/worried-partner.yaml @@ -0,0 +1 @@ +# Triangle: worried-partner diff --git a/content/global/contraband/.gitkeep b/content/global/contraband/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/content/global/enums/access-tiers.yaml b/content/global/enums/access-tiers.yaml new file mode 100644 index 000000000..21412494d --- /dev/null +++ b/content/global/enums/access-tiers.yaml @@ -0,0 +1 @@ +# Access tiers: public, insider, authority, peer, hostile diff --git a/content/global/enums/moods.yaml b/content/global/enums/moods.yaml new file mode 100644 index 000000000..c676a3f57 --- /dev/null +++ b/content/global/enums/moods.yaml @@ -0,0 +1 @@ +# 8 mood values diff --git a/content/global/enums/motivations.yaml b/content/global/enums/motivations.yaml new file mode 100644 index 000000000..aec994dca --- /dev/null +++ b/content/global/enums/motivations.yaml @@ -0,0 +1 @@ +# 6 functional motivations (System B) diff --git a/content/global/enums/patterns.yaml b/content/global/enums/patterns.yaml new file mode 100644 index 000000000..60ea55e39 --- /dev/null +++ b/content/global/enums/patterns.yaml @@ -0,0 +1 @@ +# 9 thematic patterns (System A) diff --git a/content/global/enums/situations.yaml b/content/global/enums/situations.yaml new file mode 100644 index 000000000..f1c8245c7 --- /dev/null +++ b/content/global/enums/situations.yaml @@ -0,0 +1 @@ +# 13 situation values (D-035) diff --git a/content/global/enums/topics.yaml b/content/global/enums/topics.yaml new file mode 100644 index 000000000..c610c1bfc --- /dev/null +++ b/content/global/enums/topics.yaml @@ -0,0 +1 @@ +# 9 topic values diff --git a/content/global/enums/triggers.yaml b/content/global/enums/triggers.yaml new file mode 100644 index 000000000..c74009f32 --- /dev/null +++ b/content/global/enums/triggers.yaml @@ -0,0 +1 @@ +# 9 monologue trigger types diff --git a/content/global/enums/trust-tiers.yaml b/content/global/enums/trust-tiers.yaml new file mode 100644 index 000000000..d5e8f8ae8 --- /dev/null +++ b/content/global/enums/trust-tiers.yaml @@ -0,0 +1 @@ +# Trust tiers: surface, real, secret diff --git a/content/global/factions/concord-assembly.yaml b/content/global/factions/concord-assembly.yaml new file mode 100644 index 000000000..bb6cde3c1 --- /dev/null +++ b/content/global/factions/concord-assembly.yaml @@ -0,0 +1 @@ +# Faction: concord-assembly diff --git a/content/global/factions/guardians-of-autonomy.yaml b/content/global/factions/guardians-of-autonomy.yaml new file mode 100644 index 000000000..40826906e --- /dev/null +++ b/content/global/factions/guardians-of-autonomy.yaml @@ -0,0 +1 @@ +# Faction: guardians-of-autonomy diff --git a/content/global/factions/lattice-commission.yaml b/content/global/factions/lattice-commission.yaml new file mode 100644 index 000000000..b728e7282 --- /dev/null +++ b/content/global/factions/lattice-commission.yaml @@ -0,0 +1 @@ +# Faction: lattice-commission diff --git a/content/global/factions/syndics.yaml b/content/global/factions/syndics.yaml new file mode 100644 index 000000000..a5e45779b --- /dev/null +++ b/content/global/factions/syndics.yaml @@ -0,0 +1 @@ +# Faction: syndics diff --git a/content/global/factions/the-ring.yaml b/content/global/factions/the-ring.yaml new file mode 100644 index 000000000..1690293b0 --- /dev/null +++ b/content/global/factions/the-ring.yaml @@ -0,0 +1 @@ +# Faction: the-ring diff --git a/content/global/factions/the-unbound.yaml b/content/global/factions/the-unbound.yaml new file mode 100644 index 000000000..6d1041c66 --- /dev/null +++ b/content/global/factions/the-unbound.yaml @@ -0,0 +1 @@ +# Faction: the-unbound diff --git a/content/global/factions/veil-institute.yaml b/content/global/factions/veil-institute.yaml new file mode 100644 index 000000000..a64e88d76 --- /dev/null +++ b/content/global/factions/veil-institute.yaml @@ -0,0 +1 @@ +# Faction: veil-institute diff --git a/content/global/knowledge/contraband.yaml b/content/global/knowledge/contraband.yaml new file mode 100644 index 000000000..df2c774aa --- /dev/null +++ b/content/global/knowledge/contraband.yaml @@ -0,0 +1 @@ +# Fact catalog: contraband diff --git a/content/global/knowledge/entity-attributes.yaml b/content/global/knowledge/entity-attributes.yaml new file mode 100644 index 000000000..c99baed2c --- /dev/null +++ b/content/global/knowledge/entity-attributes.yaml @@ -0,0 +1 @@ +# Entity attributes — 16 canonical EntityKnowledge keys (D-055) diff --git a/content/global/knowledge/investigation.yaml b/content/global/knowledge/investigation.yaml new file mode 100644 index 000000000..0e79cb5c4 --- /dev/null +++ b/content/global/knowledge/investigation.yaml @@ -0,0 +1 @@ +# Fact catalog: investigation diff --git a/content/global/knowledge/location.yaml b/content/global/knowledge/location.yaml new file mode 100644 index 000000000..f87eec878 --- /dev/null +++ b/content/global/knowledge/location.yaml @@ -0,0 +1 @@ +# Fact catalog: location diff --git a/content/global/knowledge/progress.yaml b/content/global/knowledge/progress.yaml new file mode 100644 index 000000000..4e93de846 --- /dev/null +++ b/content/global/knowledge/progress.yaml @@ -0,0 +1 @@ +# Fact catalog: progress diff --git a/content/global/knowledge/relationship.yaml b/content/global/knowledge/relationship.yaml new file mode 100644 index 000000000..00943bb27 --- /dev/null +++ b/content/global/knowledge/relationship.yaml @@ -0,0 +1 @@ +# Fact catalog: relationship diff --git a/content/global/knowledge/world.yaml b/content/global/knowledge/world.yaml new file mode 100644 index 000000000..4d3bd7164 --- /dev/null +++ b/content/global/knowledge/world.yaml @@ -0,0 +1 @@ +# Fact catalog: world diff --git a/content/global/regions/krenn.yaml b/content/global/regions/krenn.yaml new file mode 100644 index 000000000..689895b1a --- /dev/null +++ b/content/global/regions/krenn.yaml @@ -0,0 +1 @@ +# Krenn System — regional metadata (D-036) diff --git a/content/global/technology/.gitkeep b/content/global/technology/.gitkeep new file mode 100644 index 000000000..e69de29bb From ad55dfaa727777a3e928c7692958bbc71662bbdc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 19:48:21 +0100 Subject: [PATCH 03/21] feat(data): add content schema definitions JSON Schema (draft 2020-12) for content validation: district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, and fact-catalog. NPC schema uses if/then conditional for FRIEND pattern validation. Implements ticket #386. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- content/_schema/dialogue-pool.schema.json | 106 +++++++++++ content/_schema/district.schema.json | 49 +++++ content/_schema/fact-catalog.schema.json | 73 +++++++ content/_schema/location.schema.json | 54 ++++++ content/_schema/monologue-pool.schema.json | 117 ++++++++++++ content/_schema/npc-profile.schema.json | 209 +++++++++++++++++++++ content/_schema/routine.schema.json | 103 ++++++++++ content/_schema/triangle.schema.json | 99 ++++++++++ 8 files changed, 810 insertions(+) create mode 100644 content/_schema/dialogue-pool.schema.json create mode 100644 content/_schema/district.schema.json create mode 100644 content/_schema/fact-catalog.schema.json create mode 100644 content/_schema/location.schema.json create mode 100644 content/_schema/monologue-pool.schema.json create mode 100644 content/_schema/npc-profile.schema.json create mode 100644 content/_schema/routine.schema.json create mode 100644 content/_schema/triangle.schema.json diff --git a/content/_schema/dialogue-pool.schema.json b/content/_schema/dialogue-pool.schema.json new file mode 100644 index 000000000..da764d30d --- /dev/null +++ b/content/_schema/dialogue-pool.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "dialogue-pool.schema.json", + "title": "Dialogue Line Pool", + "description": "Tagged dialogue lines scoped by location + role (D-028, D-035).", + "type": "object", + "required": ["location", "role", "lines"], + "additionalProperties": false, + "properties": { + "location": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Location slug this dialogue pool belongs to" + }, + "role": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Template role slug (e.g. dock-worker, bar-owner)" + }, + "lines": { + "type": "array", + "items": { "$ref": "#/$defs/dialogue_line" }, + "minItems": 1 + } + }, + "$defs": { + "dialogue_line": { + "type": "object", + "required": ["id", "text", "role", "access", "trust", "situation"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$", + "description": "Stable line ID: {location_slug}_d_{###}" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Template role this line belongs to" + }, + "access": { + "type": "array", + "items": { + "type": "string", + "enum": ["public", "insider", "authority", "peer", "hostile"] + }, + "minItems": 1, + "uniqueItems": true, + "description": "Access tiers this line is available at (multi-tier eligibility)" + }, + "trust": { + "type": "string", + "enum": ["surface", "real", "secret"], + "description": "Minimum trust level required" + }, + "situation": { + "type": "string", + "enum": [ + "arrival", "shift_start", "shift_end", "shift_transition", + "bar_evening", "night_shift", "investigation", "confrontation", + "social", "alone", "emergency", "routine", "observation" + ], + "description": "Situation context when this line can fire" + }, + "topic": { + "type": "string", + "enum": [ + "colleague", "routine", "cargo", "money", "trust", + "danger", "institution", "personal", "investigation" + ], + "description": "Topic tag for selection weighting" + }, + "mood": { + "type": "string", + "enum": [ + "fond", "comfortable", "worried", "suspicious", + "analytical", "conflicted", "concerned", "relieved" + ], + "description": "Mood tag for selection weighting" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Freeform tags for additional filtering" + }, + "knowledge_grant": { + "type": "object", + "description": "Knowledge the player gains from hearing this line", + "properties": { + "fact_id": { "type": "string" }, + "confidence": { + "type": "string", + "enum": ["suspects", "knows_of", "knows_details", "direct"] + } + }, + "required": ["fact_id", "confidence"] + } + } + } + } +} diff --git a/content/_schema/district.schema.json b/content/_schema/district.schema.json new file mode 100644 index 000000000..564540542 --- /dev/null +++ b/content/_schema/district.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "district.schema.json", + "title": "District Metadata", + "description": "District definition file — one per district directory (D-036).", + "type": "object", + "required": ["canonical_id", "display_name", "system", "station", "district", "description", "locations", "npc_count"], + "additionalProperties": false, + "properties": { + "canonical_id": { + "type": "string", + "pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+$", + "description": "Canonical ID in format {system}.{station}.{district}" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "system": { + "type": "string", + "pattern": "^[a-z]+$" + }, + "station": { + "type": "string", + "pattern": "^[a-z]+$" + }, + "district": { + "type": "string", + "pattern": "^[a-z-]+$" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "locations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "minItems": 1, + "uniqueItems": true + }, + "npc_count": { + "type": "integer", + "minimum": 0 + } + } +} diff --git a/content/_schema/fact-catalog.schema.json b/content/_schema/fact-catalog.schema.json new file mode 100644 index 000000000..e1a6063f0 --- /dev/null +++ b/content/_schema/fact-catalog.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "fact-catalog.schema.json", + "title": "Fact Catalog", + "description": "Fact definitions — one file per fact category in global/knowledge/ (D-035).", + "type": "object", + "required": ["category", "facts"], + "additionalProperties": false, + "properties": { + "category": { + "type": "string", + "description": "Fact category matching the filename" + }, + "facts": { + "type": "array", + "items": { "$ref": "#/$defs/fact" }, + "minItems": 1 + } + }, + "$defs": { + "fact": { + "type": "object", + "required": ["fact_id", "description", "discoverable_by"], + "additionalProperties": false, + "properties": { + "fact_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$", + "description": "Stable unique fact identifier" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "discoverable_by": { + "type": "array", + "items": { + "type": "string", + "enum": ["smuggler", "detective"] + }, + "minItems": 1, + "uniqueItems": true, + "description": "Which playable characters can discover this fact" + }, + "abstract": { + "type": "boolean", + "default": false, + "description": "If true, fact cannot reach Direct confidence (only inferred)" + }, + "progression": { + "type": "array", + "items": { "$ref": "#/$defs/confidence_level" }, + "description": "Confidence level descriptions in ascending order" + } + } + }, + "confidence_level": { + "type": "object", + "required": ["confidence", "text"], + "additionalProperties": false, + "properties": { + "confidence": { + "type": "string", + "enum": ["suspects", "knows_of", "knows_details", "direct"] + }, + "text": { + "type": "string", + "description": "Player-facing description at this confidence level" + } + } + } + } +} diff --git a/content/_schema/location.schema.json b/content/_schema/location.schema.json new file mode 100644 index 000000000..77362c9b9 --- /dev/null +++ b/content/_schema/location.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "location.schema.json", + "title": "Location Definition", + "description": "Location metadata — one file per location (D-025, D-036).", + "type": "object", + "required": ["canonical_id", "display_name"], + "additionalProperties": false, + "properties": { + "canonical_id": { + "type": "string", + "pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+\\.location\\.[a-z][a-z0-9-]*$", + "description": "Full canonical ID: {system}.{station}.{district}.location.{slug}" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "tile_bounds": { + "type": "object", + "description": "Rectangular tile bounds for this location", + "properties": { + "x_min": { "type": "integer" }, + "y_min": { "type": "integer" }, + "x_max": { "type": "integer" }, + "y_max": { "type": "integer" }, + "z": { "type": "integer" } + }, + "required": ["x_min", "y_min", "x_max", "y_max", "z"] + }, + "sightlines": { + "type": "object", + "description": "Sightline properties for LOS computation", + "properties": { + "open": { + "type": "boolean", + "description": "True if the location is open-plan (no internal walls)" + }, + "notes": { "type": "string" } + } + }, + "ambient_sound": { + "type": "string", + "description": "Reference to ambient sound asset" + }, + "social_site": { + "type": "string", + "description": "Social site template this location belongs to" + } + } +} diff --git a/content/_schema/monologue-pool.schema.json b/content/_schema/monologue-pool.schema.json new file mode 100644 index 000000000..e266b5d32 --- /dev/null +++ b/content/_schema/monologue-pool.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "monologue-pool.schema.json", + "title": "Monologue Line Pool", + "description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).", + "type": "object", + "required": ["character", "location", "lines"], + "additionalProperties": false, + "properties": { + "character": { + "type": "string", + "enum": ["smuggler", "detective"], + "description": "Playable character this pool belongs to (hard partition per D-032)" + }, + "location": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Location slug, or 'general' for location-independent lines" + }, + "lines": { + "type": "array", + "items": { "$ref": "#/$defs/monologue_line" }, + "minItems": 1 + } + }, + "$defs": { + "monologue_line": { + "type": "object", + "required": ["id", "text", "trigger"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$", + "description": "Stable line ID: {location_slug}_m_{s|d}_{###}" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "Line text — 160 char max per D-059" + }, + "trigger": { + "type": "string", + "enum": [ + "enter_location", "observe_npc", "hear_sound", + "observe_anomaly", "post_conversation", "discover_evidence", + "witness_interaction", "time_idle", "return_visit" + ], + "description": "What causes this line to fire" + }, + "prerequisites": { + "type": "object", + "description": "AND-only prerequisite conditions", + "properties": { + "facts": { + "type": "array", + "items": { "$ref": "#/$defs/fact_prerequisite" } + }, + "entity_attributes": { + "type": "array", + "items": { "$ref": "#/$defs/attribute_prerequisite" } + }, + "relationship": { + "type": "object", + "properties": { + "target": { "type": "string" }, + "state": { + "type": "string", + "enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"] + } + } + } + } + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 5, + "description": "Selection priority (higher = more likely to fire)" + }, + "cooldown": { + "type": "integer", + "minimum": 0, + "description": "Minimum ticks before this line can fire again" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "fact_prerequisite": { + "type": "object", + "required": ["fact_id", "min_confidence"], + "additionalProperties": false, + "properties": { + "fact_id": { "type": "string" }, + "min_confidence": { + "type": "string", + "enum": ["suspects", "knows_of", "knows_details", "direct"] + } + } + }, + "attribute_prerequisite": { + "type": "object", + "required": ["entity", "key", "value"], + "additionalProperties": false, + "properties": { + "entity": { "type": "string" }, + "key": { "type": "string" }, + "value": { "type": "string" } + } + } + } +} diff --git a/content/_schema/npc-profile.schema.json b/content/_schema/npc-profile.schema.json new file mode 100644 index 000000000..584135461 --- /dev/null +++ b/content/_schema/npc-profile.schema.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "npc-profile.schema.json", + "title": "NPC Profile", + "description": "10-axis NPC model profile — one file per NPC (D-024, D-034).", + "type": "object", + "required": ["canonical_id", "display_name", "tier", "pattern", "motivation"], + "additionalProperties": false, + "properties": { + "canonical_id": { + "type": "string", + "pattern": "^npc:[a-z][a-z0-9-]*$", + "description": "Short-form canonical ID: npc:{slug}" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "tier": { + "type": "integer", + "enum": [1, 2, 3], + "description": "NPC tier: 1 = conspiracy (full depth), 2 = template (social context), 3 = filler (atmosphere)" + }, + "pattern": { + "type": "string", + "enum": ["FRIEND", "MIRROR", "ANCHOR", "GHOST", "CATALYST", "THRESHOLD", "REMNANT", "SYSTEM", "NOBODY"], + "description": "Thematic pattern — System A (D-050)" + }, + "motivation": { + "type": "string", + "enum": ["HANDLER", "WITNESS", "TURNCOAT", "CIVILIAN", "OPERATOR", "SKEPTIC"], + "description": "Functional motivation — System B (D-050)" + }, + "description": { + "type": "string" + }, + "want": { + "type": "object", + "description": "Primary want/need driving this NPC", + "properties": { + "primary": { "type": "string" }, + "intensity": { "type": "integer", "minimum": 0, "maximum": 10 }, + "description": { "type": "string" } + }, + "required": ["primary"] + }, + "secret": { + "type": "string", + "description": "The NPC's hidden truth — what they don't want the player to know" + }, + "relationships": { + "type": "array", + "items": { "$ref": "#/$defs/relationship" }, + "description": "Named relationships to other NPCs" + }, + "tolerance": { + "type": "object", + "description": "Stress tolerance threshold", + "properties": { + "threshold": { "type": "integer" }, + "description": { "type": "string" } + } + }, + "routine": { + "type": "object", + "description": "Summary of daily routine (full schedule in routines/schedules.yaml)", + "properties": { + "summary": { "type": "string" } + } + }, + "information": { + "type": "object", + "description": "What this NPC knows", + "properties": { + "knows": { + "type": "array", + "items": { "type": "string" }, + "description": "Fact IDs this NPC knows" + }, + "access_tier": { + "type": "string", + "enum": ["public", "insider", "authority", "peer", "hostile"] + } + } + }, + "contentment": { + "type": "object", + "properties": { + "level": { "type": "integer", "minimum": -10, "maximum": 10 }, + "description": { "type": "string" } + } + }, + "personality": { + "type": "object", + "description": "Personality traits and behavioral tendencies", + "additionalProperties": { "type": "string" } + }, + "tells": { + "type": "array", + "items": { "$ref": "#/$defs/tell" }, + "description": "Observable behavioral tells (D-024)" + }, + "skills": { + "type": "object", + "description": "Skill set and combat capability", + "properties": { + "combat_trained": { "type": "boolean" }, + "skills": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0, "maximum": 10 } + } + } + }, + "triangle_membership": { + "type": "array", + "items": { "type": "string" }, + "description": "Triangle slugs this NPC participates in" + }, + "trust_levels": { + "type": "object", + "description": "What the NPC reveals at each trust level", + "properties": { + "surface": { "type": "string" }, + "real": { "type": "string" }, + "secret": { "type": "string" } + } + }, + "friend_arc": { + "type": "object", + "description": "FRIEND arc data — only valid on FRIEND-pattern Tier 1 NPCs (D-034)", + "properties": { + "bonded_character": { + "type": "string", + "enum": ["smuggler", "detective"] + }, + "phases": { + "type": "array", + "items": { "$ref": "#/$defs/friend_phase" } + } + }, + "required": ["bonded_character", "phases"] + }, + "dual_lens": { + "type": "object", + "description": "Authoring-only: how smuggler vs detective perceives this NPC", + "properties": { + "smuggler": { "type": "string" }, + "detective": { "type": "string" } + } + }, + "notes": { + "type": "string", + "description": "Authoring-only: design notes" + } + }, + "if": { + "properties": { "pattern": { "const": "FRIEND" } } + }, + "then": { + "required": ["friend_arc"] + }, + "$defs": { + "relationship": { + "type": "object", + "required": ["target", "kind"], + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "pattern": "^npc:[a-z][a-z0-9-]*$" + }, + "kind": { + "type": "string", + "enum": ["colleague", "friend", "rival", "romantic", "family", "superior", "subordinate"] + }, + "trust": { + "type": "integer", + "minimum": -10, + "maximum": 10 + }, + "notes": { "type": "string" } + } + }, + "tell": { + "type": "object", + "required": ["trigger", "behavior"], + "additionalProperties": false, + "properties": { + "trigger": { "type": "string" }, + "behavior": { "type": "string" }, + "visible_to": { + "type": "string", + "enum": ["forward", "peripheral", "any"] + } + } + }, + "friend_phase": { + "type": "object", + "required": ["phase", "description"], + "additionalProperties": false, + "properties": { + "phase": { "type": "integer", "minimum": 1, "maximum": 5 }, + "description": { "type": "string" }, + "trigger": { "type": "string" }, + "routine_deviation": { "type": "string" } + } + } + } +} diff --git a/content/_schema/routine.schema.json b/content/_schema/routine.schema.json new file mode 100644 index 000000000..c8e13e8fb --- /dev/null +++ b/content/_schema/routine.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "routine.schema.json", + "title": "NPC Routine Schedules", + "description": "Daily routine schedules — all NPCs in one file per district for cross-NPC validation (D-034).", + "type": "object", + "required": ["district", "schedules"], + "additionalProperties": false, + "properties": { + "district": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "District slug this routine file belongs to" + }, + "schedules": { + "type": "array", + "items": { "$ref": "#/$defs/npc_schedule" }, + "minItems": 1 + } + }, + "$defs": { + "npc_schedule": { + "type": "object", + "required": ["npc", "entries"], + "additionalProperties": false, + "properties": { + "npc": { + "type": "string", + "pattern": "^npc:[a-z][a-z0-9-]*$", + "description": "NPC short-form canonical ID" + }, + "entries": { + "type": "array", + "items": { "$ref": "#/$defs/routine_entry" }, + "minItems": 1 + }, + "deviations": { + "type": "array", + "items": { "$ref": "#/$defs/deviation" }, + "description": "Conditional schedule overrides (FRIEND arc staging, etc.)" + } + } + }, + "routine_entry": { + "type": "object", + "required": ["phase", "location"], + "additionalProperties": false, + "properties": { + "phase": { + "type": "string", + "enum": ["morning", "afternoon", "evening", "night"], + "description": "Day phase for this entry" + }, + "location": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Location slug" + }, + "tile": { + "type": "object", + "properties": { + "x": { "type": "integer" }, + "y": { "type": "integer" } + }, + "required": ["x", "y"], + "description": "Specific tile coordinates within the location" + }, + "activity": { + "type": "string", + "description": "What the NPC is doing at this location/time" + } + } + }, + "deviation": { + "type": "object", + "required": ["trigger", "location"], + "additionalProperties": false, + "properties": { + "trigger": { + "type": "string", + "description": "Condition that activates this deviation" + }, + "phase": { + "type": "string", + "enum": ["morning", "afternoon", "evening", "night"] + }, + "location": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "tile": { + "type": "object", + "properties": { + "x": { "type": "integer" }, + "y": { "type": "integer" } + }, + "required": ["x", "y"] + }, + "activity": { "type": "string" } + } + } + } +} diff --git a/content/_schema/triangle.schema.json b/content/_schema/triangle.schema.json new file mode 100644 index 000000000..55b594a90 --- /dev/null +++ b/content/_schema/triangle.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "triangle.schema.json", + "title": "Triangle Definition", + "description": "3-NPC relationship triangle — self-contained forks, no cross-triangle cascade in v0.1 (D-024, D-047).", + "type": "object", + "required": ["canonical_id", "display_name", "members"], + "additionalProperties": false, + "properties": { + "canonical_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Triangle slug" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "members": { + "type": "array", + "items": { "$ref": "#/$defs/member" }, + "minItems": 3, + "maxItems": 3, + "description": "Exactly 3 NPC members" + }, + "forks": { + "type": "array", + "items": { "$ref": "#/$defs/fork" }, + "description": "Possible fork points in this triangle" + }, + "resolution_states": { + "type": "array", + "items": { "$ref": "#/$defs/resolution" }, + "description": "Terminal states this triangle can reach" + } + }, + "$defs": { + "member": { + "type": "object", + "required": ["npc", "role"], + "additionalProperties": false, + "properties": { + "npc": { + "type": "string", + "pattern": "^npc:[a-z][a-z0-9-]*$", + "description": "NPC short-form canonical ID" + }, + "role": { + "type": "string", + "description": "This NPC's role within the triangle" + } + } + }, + "fork": { + "type": "object", + "required": ["id", "condition"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "condition": { + "type": "string", + "description": "What triggers this fork" + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "effects": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "resolution": { + "type": "object", + "required": ["id", "description"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "description": { "type": "string" } + } + } + } +} From 5afea996225c2598e22282c75321b2698e713d49 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 19:48:43 +0100 Subject: [PATCH 04/21] feat(simulation): add tick rate scaling system Replace binary paused flag with TickRate enum (Full/Half/Paused) per D-052. Full rate advances every frame, Half every 2 frames via fractional accumulation, Paused blocks all advances. Add SetTickRate player action for client-driven rate changes. Implements ticket #406. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/knowledge/events.rs | 4 +- server/src/simulation/input.rs | 52 +++++++----- server/src/simulation/time.rs | 143 +++++++++++++++++++++++---------- 3 files changed, 132 insertions(+), 67 deletions(-) diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index c81cad404..ea102ae66 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -260,7 +260,7 @@ mod tests { world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run - world.insert_resource(SimulationTime { tick: 7, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); @@ -273,7 +273,7 @@ mod tests { ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) - world.insert_resource(SimulationTime { tick: 10, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index f5e9a2ffc..72bf3f278 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -5,7 +5,7 @@ use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; -use crate::simulation::time::SimulationTime; +use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::prelude::*; use std::collections::VecDeque; @@ -75,13 +75,17 @@ pub fn process_player_input( PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1), PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1), PlayerAction::Pause => { - time.paused = true; + time.tick_rate = TickRate::Paused; tracing::debug!("Simulation paused by player input"); } PlayerAction::Unpause => { - time.paused = false; + time.tick_rate = TickRate::Full; tracing::debug!("Simulation unpaused by player input"); } + PlayerAction::SetTickRate(rate) => { + time.tick_rate = rate; + tracing::debug!("Tick rate set to {:?} by player input", rate); + } PlayerAction::Interact => { tracing::trace!("Interact action — no-op for Sprint 1"); } @@ -160,10 +164,7 @@ mod tests { fn process_input_move_creates_intent() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) @@ -183,13 +184,10 @@ mod tests { } #[test] - fn process_input_pause_toggles() { + fn process_input_pause_sets_paused() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); world.resource_mut::<InputQueue>().push(PlayerInput { tick: 0, @@ -200,17 +198,32 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - assert!(world.resource::<SimulationTime>().paused); + assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Paused); + } + + #[test] + fn process_input_set_tick_rate() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + world.resource_mut::<InputQueue>().push(PlayerInput { + tick: 0, + action: PlayerAction::SetTickRate(TickRate::Half), + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Half); } #[test] fn process_input_no_player_no_panic() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); world.resource_mut::<InputQueue>().push(PlayerInput { tick: 0, @@ -227,10 +240,7 @@ mod tests { fn process_input_future_tick_ignored() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index 753eefc1c..dd80aad2f 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -18,12 +18,54 @@ pub enum DayPhase { Night, } +/// Tick rate states per D-052 +/// Full: normal gameplay. Half: UI overlay open (knowledge panel, dialogue). +/// Paused: spacebar pause (0 ticks advance). +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub enum TickRate { + #[default] + Full, + Half, + Paused, +} + +impl TickRate { + /// Scale factor: Full=1.0, Half=0.5, Paused=0.0 + pub fn scale(self) -> f32 { + match self { + TickRate::Full => 1.0, + TickRate::Half => 0.5, + TickRate::Paused => 0.0, + } + } +} + /// Simulation time resource -/// Tracks current tick and pause state for deterministic simulation -#[derive(Resource, Debug, Clone, Default)] +/// Tracks current tick and tick rate for deterministic simulation (D-052). +/// Uses fractional accumulation: at Half speed, one tick advances every 2 frames. +#[derive(Resource, Debug, Clone)] pub struct SimulationTime { pub tick: u64, - pub paused: bool, + pub tick_rate: TickRate, + /// Fractional tick accumulator for sub-1.0 rates + accumulated: f32, +} + +impl Default for SimulationTime { + fn default() -> Self { + Self { + tick: 0, + tick_rate: TickRate::Full, + accumulated: 0.0, + } + } +} + +impl SimulationTime { + /// Whether the simulation is effectively paused (tick_rate == Paused) + pub fn paused(&self) -> bool { + self.tick_rate == TickRate::Paused + } } impl SimulationTime { @@ -48,10 +90,17 @@ impl SimulationTime { } } -/// Advance the simulation tick if not paused +/// Advance the simulation tick based on current tick rate. +/// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance. pub fn advance_tick(mut time: ResMut<SimulationTime>) { - if !time.paused { + let scale = time.tick_rate.scale(); + if scale <= 0.0 { + return; + } + time.accumulated += scale; + if time.accumulated >= 1.0 { time.tick += 1; + time.accumulated -= 1.0; } } @@ -61,44 +110,26 @@ mod tests { #[test] fn tick_to_minute_conversion() { - let time = SimulationTime { - tick: 10, - paused: false, - }; + let time = SimulationTime { tick: 10, ..Default::default() }; assert_eq!(time.game_minutes(), 1); } #[test] fn day_phase_boundaries() { - let time = SimulationTime { - tick: 0, - paused: false, - }; + let time = SimulationTime::default(); assert_eq!(time.day_phase(), DayPhase::Morning); - let time = SimulationTime { - tick: 360 * TICKS_PER_GAME_MINUTE, - paused: false, - }; + let time = SimulationTime { tick: 360 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Afternoon); - let time = SimulationTime { - tick: 720 * TICKS_PER_GAME_MINUTE, - paused: false, - }; + let time = SimulationTime { tick: 720 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Evening); - let time = SimulationTime { - tick: 1080 * TICKS_PER_GAME_MINUTE, - paused: false, - }; + let time = SimulationTime { tick: 1080 * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Night); } #[test] - fn pause_prevents_tick_advance() { + fn paused_prevents_tick_advance() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { - tick: 0, - paused: true, - }); + world.insert_resource(SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); schedule.run(&mut world); @@ -106,25 +137,52 @@ mod tests { } #[test] - fn unpause_allows_tick_advance() { + fn full_rate_advances_every_frame() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { - tick: 0, - paused: false, - }); + world.insert_resource(SimulationTime::default()); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); schedule.run(&mut world); assert_eq!(world.resource::<SimulationTime>().tick, 1); } + #[test] + fn half_rate_advances_every_two_frames() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(advance_tick); + + // Frame 1: accumulate 0.5, no tick + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 0); + + // Frame 2: accumulate 1.0, tick advances + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 1); + + // Frame 3: accumulate 0.5 again, no tick + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 1); + + // Frame 4: tick advances again + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 2); + } + + #[test] + fn paused_helper_method() { + let time = SimulationTime::default(); + assert!(!time.paused()); + let time = SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }; + assert!(time.paused()); + let time = SimulationTime { tick_rate: TickRate::Half, ..Default::default() }; + assert!(!time.paused()); + } + #[test] fn day_wraparound_at_midnight() { - // 1440 minutes = 1 full day, should wrap back to Morning - let time = SimulationTime { - tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, - paused: false, - }; + let time = SimulationTime { tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, ..Default::default() }; assert_eq!(time.day_phase(), DayPhase::Morning); assert_eq!(time.time_of_day_minutes(), 0); assert_eq!(time.day(), 1); @@ -132,10 +190,7 @@ mod tests { #[test] fn day_calculation() { - let time = SimulationTime { - tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, - paused: false, - }; + let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() }; assert_eq!(time.day(), 3); } } From 2de413dd87adcae4abce950ae8c9e1635b6bb969 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 19:48:55 +0100 Subject: [PATCH 05/21] feat(simulation): add proximity detection and interaction verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement compute_nearby_interactions system that detects entities within close (≤2) and mid (≤5) Manhattan distance, computes available verbs per D-060 spec. NPCs get Talk+Observe at close range, Observe-only at mid range; PersonOfInterest flips priority. Objects get Examine. Results populate nearby_interactions[] on ObserverSnapshot v4. Bump protocol version 3→4. Implements #404. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/bridge/mod.rs | 6 +- server/src/bridge/types.rs | 53 +++- server/src/perception/interpretation.rs | 1 + server/src/perception/observation.rs | 1 + server/src/perception/observer.rs | 23 +- server/src/simulation/interaction.rs | 332 ++++++++++++++++++++++++ server/src/simulation/mod.rs | 9 +- server/tests/bridge_ipc.rs | 6 +- server/tests/bridge_tcp.rs | 6 +- server/tests/game_loop.rs | 2 +- server/tests/gen_fixtures.rs | 10 +- server/tests/serialization.rs | 18 +- 12 files changed, 438 insertions(+), 29 deletions(-) create mode 100644 server/src/simulation/interaction.rs diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 17301d47f..8e3267879 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -102,7 +102,8 @@ pub fn generate_snapshot( day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused, + paused: time.paused(), + tick_rate: time.tick_rate, }; tracing::trace!( @@ -111,12 +112,13 @@ pub fn generate_snapshot( visible.len() ); buffer.snapshot = Some(ObserverSnapshot { - version: 3, + version: 4, tick: time.tick, game_time, player_facing: FacingDirection::default(), entities: visible, visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering + nearby_interactions: Vec::new(), }); } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d3a2a7f53..cbebfe2ac 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -6,18 +6,19 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; -pub use crate::simulation::time::DayPhase; +pub use crate::simulation::time::{DayPhase, TickRate}; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. /// /// v2 adds: game_time, player_facing, visible_tiles, visibility sectors. /// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered). +/// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]). /// Future fields: ambient sound events, internal monologue triggers, /// HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 3. + /// Protocol version for forward compatibility. Current: 4. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -29,6 +30,10 @@ pub struct ObserverSnapshot { pub entities: Vec<VisibleEntity>, /// Tiles visible to the observer for fog rendering pub visible_tiles: Vec<VisibleTile>, + /// Entities within interaction range with available verbs (D-060, #404). + /// Sorted by distance (nearest first). v0.1 client reads verbs[0] on the nearest entity. + #[serde(default)] + pub nearby_interactions: Vec<NearbyInteraction>, } /// Game time data for client display (D-031) @@ -41,8 +46,11 @@ pub struct GameTime { pub time_of_day: u64, /// Current day phase (Morning/Afternoon/Evening/Night) pub day_phase: DayPhase, - /// Whether simulation is paused + /// Whether simulation is paused (tick_rate == Paused) pub paused: bool, + /// Current tick rate state (D-052) + #[serde(default)] + pub tick_rate: TickRate, } /// 8-directional facing direction, matching movement system. @@ -136,6 +144,45 @@ pub enum PlayerAction { UsePerceptionMode(String), Pause, Unpause, + /// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052 + SetTickRate(TickRate), +} + +/// Available interaction verbs for a nearby entity (D-060, #404) +/// Embedded in ObserverSnapshot.nearby_interactions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NearbyInteraction { + /// Wire-format entity identifier + pub entity_id: u64, + /// Entity type for client-side verb display + pub entity_type: EntityKind, + /// Manhattan distance from player + pub distance: f32, + /// Available verbs sorted by priority (index 0 = highest priority) + pub verbs: Vec<VerbOption>, +} + +/// A single available verb on a nearby entity +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerbOption { + /// Verb type + pub kind: VerbKind, + /// Display label for the context prompt (e.g. "Talk", "Observe", "Examine") + pub label: String, + /// Priority rank (lower = higher priority). v0.1 client reads only priority 1. + pub priority: u8, + /// Whether this verb is currently available (false = greyed out in v0.2) + pub available: bool, +} + +/// Verb types for the interaction system (D-060) +/// Only active verbs appear in verbs[]. Passive (Look, Overhear) and +/// reactive (Monologue) verbs fire independently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum VerbKind { + ExamineObject, + ExamineNpc, + Talk, } /// Snapshot buffer resource for staging outgoing ObserverSnapshots diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index d09ce6887..f77474c21 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -199,6 +199,7 @@ mod tests { world.init_resource::<crate::knowledge::KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); world.init_resource::<ObservationEventQueue>(); + world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>(); world } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 31e91ff1f..ee5fa9d85 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -99,6 +99,7 @@ mod tests { world.init_resource::<SnapshotBuffer>(); world.init_resource::<KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); + world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>(); world } diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 0a096e0a4..bbee9457a 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -11,6 +11,7 @@ use crate::bridge::types::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; +use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::time::SimulationTime; @@ -22,6 +23,7 @@ pub fn compute_observer_snapshot( time: Res<SimulationTime>, walkability: Res<WalkabilityMap>, registry: Res<EntityRegistry>, + interaction_buffer: Res<NearbyInteractionBuffer>, observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>, all_entities: Query<( Entity, @@ -185,7 +187,8 @@ pub fn compute_observer_snapshot( day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused, + paused: time.paused(), + tick_rate: time.tick_rate, }; tracing::trace!( @@ -196,14 +199,15 @@ pub fn compute_observer_snapshot( visible_tiles.len(), ); - // Step 8: Assemble snapshot (v3: added relationship + observation fields) + // Step 8: Assemble snapshot (v4: added nearby_interactions) buffer.snapshot = Some(ObserverSnapshot { - version: 3, + version: 4, tick: time.tick, game_time, player_facing: facing, entities, visible_tiles, + nearby_interactions: interaction_buffer.interactions.clone(), }); } @@ -221,6 +225,7 @@ mod tests { world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::<SnapshotBuffer>(); world.init_resource::<EntityRegistry>(); + world.init_resource::<NearbyInteractionBuffer>(); world } @@ -240,7 +245,7 @@ mod tests { let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 3); + assert_eq!(snapshot.version, 4); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -359,10 +364,10 @@ mod tests { #[test] fn game_time_populated() { let mut world = setup_world(32, 32); - world.insert_resource(SimulationTime { - tick: 7200, // 720 minutes = Evening - paused: true, - }); + let mut time = SimulationTime::default(); + time.tick = 7200; // 720 minutes = Evening + time.tick_rate = crate::simulation::time::TickRate::Paused; + world.insert_resource(time); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), @@ -479,7 +484,7 @@ mod tests { .id(); registry.register(player); world.insert_resource(registry); - world.insert_resource(SimulationTime { tick: 100, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs new file mode 100644 index 000000000..d29327519 --- /dev/null +++ b/server/src/simulation/interaction.rs @@ -0,0 +1,332 @@ +// Interaction system — proximity detection + multi-verb InteractionOptions +// Implements #404: server-side verb computation for context-sensitive [E] key +// Spec: docs/design/interaction-verbs-v0.1.md +// D-060: actions[] renamed to verbs[] across all surfaces + +use bevy_ecs::prelude::*; +use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption}; +use crate::knowledge::types::RelationshipState; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::Npc; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; + +/// Interaction range thresholds (Manhattan distance, same z-level) +pub const CLOSE_RANGE: u32 = 2; +pub const MID_RANGE: u32 = 5; + +/// Component marking an entity as having available interactions. +/// Attached to NPCs and examinable objects by the world setup or content loader. +#[derive(Component, Debug, Clone)] +pub struct Interactable; + +/// Compute nearby interactions for the player character. +/// For each visible entity in range, determines available verbs sorted by priority. +/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot. +#[allow(clippy::type_complexity)] +pub fn compute_nearby_interactions( + player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>, + registry: Res<EntityRegistry>, + interactables: Query< + (Entity, &TilePosition, Option<&Npc>), + (With<Interactable>, Without<PlayerCharacter>), + >, + mut buffer: ResMut<NearbyInteractionBuffer>, +) { + buffer.interactions.clear(); + + let Ok((player_pos, knowledge)) = player_query.single() else { + return; + }; + + for (entity, pos, is_npc) in interactables.iter() { + let Some(distance) = player_pos.manhattan_distance(pos) else { + continue; // Different z-level + }; + + if distance > MID_RANGE { + continue; // Out of interaction range + } + + let entity_type = if is_npc.is_some() { + EntityKind::Npc + } else { + EntityKind::Object + }; + + // Look up relationship state from knowledge graph + let relationship = if let Some(stable_id) = registry.to_stable(entity) { + knowledge.relationship_with(&stable_id) + } else { + RelationshipState::Unknown + }; + + let is_poi = relationship == RelationshipState::PersonOfInterest; + let is_close = distance <= CLOSE_RANGE; + + let mut verbs = Vec::new(); + + match entity_type { + EntityKind::Npc => { + if is_close { + if is_poi { + // Post-contradiction: Observe takes priority over Talk + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 2, + available: true, + }); + } else { + // Default: Talk takes priority + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 2, + available: true, + }); + } + } else { + // Mid range: only Examine NPC (Talk requires close range) + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 1, + available: true, + }); + } + } + EntityKind::Object | EntityKind::Terrain => { + if is_close { + verbs.push(VerbOption { + kind: VerbKind::ExamineObject, + label: "Examine".into(), + priority: 1, + available: true, + }); + } + } + EntityKind::Player => {} // No self-interaction + } + + if verbs.is_empty() { + continue; + } + + // Sort by priority (lower number = higher priority) + verbs.sort_by_key(|v| v.priority); + + buffer.interactions.push(NearbyInteraction { + entity_id: entity.to_bits(), + entity_type, + distance: distance as f32, + verbs, + }); + } + + // Sort interactions by distance (nearest first) + buffer + .interactions + .sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap()); +} + +/// Buffer for nearby interaction results, consumed by snapshot generation +#[derive(Resource, Debug, Default)] +pub struct NearbyInteractionBuffer { + pub interactions: Vec<NearbyInteraction>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::EntityRegistry; + use bevy_ecs::world::World; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<NearbyInteractionBuffer>(); + world + } + + #[test] + fn npc_in_close_range_gets_talk_and_observe() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + // Talk should be priority 1 (default, not POI) + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + } + + #[test] + fn npc_in_mid_range_gets_observe_only() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Distance 4 (mid range, beyond close) + world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + } + + #[test] + fn npc_out_of_range_no_interactions() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Distance 8 (beyond mid range) + world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert!(buffer.interactions.is_empty()); + } + + #[test] + fn poi_npc_observe_takes_priority() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((Npc, TilePosition::new(5, 6, 0), Interactable)) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 1); + // Observe should be priority 1 for POI NPC + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk); + } + + #[test] + fn object_in_close_range_gets_examine() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Object (no Npc component) at close range + world.spawn((TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject); + } + + #[test] + fn different_z_level_no_interactions() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert!(buffer.interactions.is_empty()); + } + + #[test] + fn multiple_entities_sorted_by_distance() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Farther NPC + world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); + // Closer NPC + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 2); + assert!(buffer.interactions[0].distance < buffer.interactions[1].distance); + } + + #[test] + fn non_interactable_entity_ignored() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // NPC without Interactable component + world.spawn((Npc, TilePosition::new(5, 6, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert!(buffer.interactions.is_empty()); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 937b3384e..086365d82 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; +pub mod interaction; pub mod movement; pub mod path_follow; pub mod pathfinding; @@ -22,6 +23,8 @@ impl Plugin for SimulationPlugin { app.init_resource::<time::SimulationTime>() .insert_resource(rng::SimRng::new(0)) .init_resource::<input::InputQueue>() + .init_resource::<interaction::NearbyInteractionBuffer>() + .init_resource::<crate::knowledge::EntityRegistry>() .add_systems( Update, ( @@ -29,8 +32,12 @@ impl Plugin for SimulationPlugin { pathfinding::compute_paths.after(input::process_player_input), path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), + interaction::compute_nearby_interactions + .after(movement::validate_movement), path_follow::cleanup_path_blocked.after(movement::validate_movement), - time::advance_tick.after(path_follow::cleanup_path_blocked), + time::advance_tick + .after(path_follow::cleanup_path_blocked) + .after(interaction::compute_nearby_interactions), ), ); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index c55bfa4a5..b2e8648ba 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::local::LocalBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::SimBridge; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::thread; @@ -33,13 +33,14 @@ fn snapshot_roundtrip_over_unix_socket() { let bridge = LocalBridge::accept(&server_path).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 42, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities: vec![VisibleEntity { @@ -53,6 +54,7 @@ fn snapshot_roundtrip_over_unix_socket() { observation: EntityVisibility::Visible, }], visible_tiles: vec![], + nearby_interactions: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index c15cc484a..b3bd68877 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::SimBridge; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::net::{TcpListener, TcpStream}; use std::thread; @@ -19,13 +19,14 @@ fn snapshot_roundtrip_over_tcp() { let bridge = TcpBridge::accept_on(listener).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 42, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities: vec![VisibleEntity { @@ -39,6 +40,7 @@ fn snapshot_roundtrip_over_tcp() { observation: EntityVisibility::Visible, }], visible_tiles: vec![], + nearby_interactions: vec![], }; bridge diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index bbced55fc..5f60df679 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -61,7 +61,7 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 3); + assert_eq!(snapshot.version, 4); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index d421b743c..7ee1ef36d 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,7 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; use std::path::Path; @@ -18,17 +18,19 @@ fn write_fixture(name: &str, bytes: &[u8]) { /// Helper to create a minimal v2 snapshot for fixtures fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot { ObserverSnapshot { - version: 3, + version: 4, tick, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities, visible_tiles: vec![], + nearby_interactions: vec![], } } @@ -150,13 +152,14 @@ fn generate_msgpack_fixtures() { // v2 snapshot with visible_tiles and game_time populated let snapshot_v2_full = ObserverSnapshot { - version: 3, + version: 4, tick: 500, game_time: GameTime { day: 1, time_of_day: 720, day_phase: DayPhase::Evening, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::Southeast, entities: vec![VisibleEntity { @@ -189,6 +192,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, }, ], + nearby_interactions: vec![], }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index a768136fe..e308963ff 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1,23 +1,25 @@ //! IPC serialization round-trip tests (D-030 Layer 1: fixture-based). use settled_reach_server::bridge::types::*; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; /// Helper to create a minimal v2 snapshot for tests fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot { ObserverSnapshot { - version: 3, + version: 4, tick, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities, visible_tiles: vec![], + nearby_interactions: vec![], } } @@ -40,7 +42,7 @@ fn observer_snapshot_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 3); + assert_eq!(decoded.version, 4); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -178,13 +180,14 @@ fn all_entity_kind_variants_roundtrip() { #[test] fn snapshot_v2_fields_roundtrip() { let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 100, game_time: GameTime { day: 3, time_of_day: 720, day_phase: DayPhase::Evening, paused: true, + tick_rate: TickRate::Paused, }, player_facing: FacingDirection::Southeast, entities: vec![VisibleEntity { @@ -211,12 +214,13 @@ fn snapshot_v2_fields_roundtrip() { visibility: VisibilitySector::Peripheral, }, ], + nearby_interactions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 3); + assert_eq!(decoded.version, 4); assert_eq!(decoded.game_time.day, 3); assert_eq!(decoded.game_time.time_of_day, 720); assert_eq!(decoded.game_time.day_phase, DayPhase::Evening); @@ -244,17 +248,19 @@ fn all_facing_direction_variants_roundtrip() { for dir in directions { let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 0, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: dir, entities: vec![], visible_tiles: vec![], + nearby_interactions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); From 458be0f6217257124a688c018a76d2ebf6ab9023 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 19:49:19 +0100 Subject: [PATCH 06/21] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 506bf6eef..f9ba6e979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag +- Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4 +- Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions +- Content schema definitions (#386) — 8 JSON Schema files (draft 2020-12) for district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, fact-catalog validation + ### Changed +- ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field with serde default for backward compatibility - Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them - Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure From 9943684f2c3bbf7bcbc2ca8826bfefc822bd04fa Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 20:14:35 +0100 Subject: [PATCH 07/21] refactor(server): address PR #16 review feedback Hoshe + Tyre review items: - Use StableId consistently for wire entity_id (H4) across observer, observation, interpretation, and interaction systems - Make NearbyInteractionBuffer.interactions private with take() (H1/H20) - Add system ordering constraint for compute_nearby_interactions (H5) - Panic on missing PlayerCharacter in input processing (H2) - Remove redundant paused field from GameTime (Tyre8) - Remove #[serde(default)] from nearby_interactions (H3) - Change NearbyInteraction.distance from f32 to u32 (H8) - Add sort stability for equal verb priorities (H6) - Scope constants to pub(crate) (H7) - Add debug_assert for last_observed_tick ordering (H10) - Strengthen unregistered entity handling to debug_assert + error (H11) - Document fractional tick accumulation (Tyre9) - Extract collect_remembered_entities helper (Tyre2/H17) - Add half_rate_no_drift_over_10000_frames test (H14) - Add mid-range and deterministic sort tests (H15) - Add fixture version assertion (H16) - Regenerate msgpack fixtures for wire format changes 146 unit + 19 integration tests pass, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../fixtures/msgpack/snapshot_empty.msgpack | Bin 116 -> 144 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 521 -> 549 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 214 -> 242 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 217 -> 245 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 315 -> 343 bytes server/src/bridge/mod.rs | 4 +- server/src/bridge/types.rs | 10 +- server/src/knowledge/events.rs | 6 +- server/src/perception/interpretation.rs | 54 +++---- server/src/perception/observation.rs | 18 +-- server/src/perception/observer.rs | 135 ++++++++++-------- server/src/simulation/input.rs | 25 ++-- server/src/simulation/interaction.rs | 82 +++++++++-- server/src/simulation/time.rs | 20 +++ server/tests/bridge_ipc.rs | 1 - server/tests/bridge_tcp.rs | 1 - server/tests/game_loop.rs | 2 +- server/tests/gen_fixtures.rs | 2 - server/tests/serialization.rs | 8 +- 19 files changed, 232 insertions(+), 136 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 1c42dbf6cb26e754439b5b723b86e48de18b9305..adfee537e0154fa7d9f2721605be264a8a41dddc 100644 GIT binary patch delta 58 zcmXS!z{u6Uyeze-I5R(wWg?d;|H_ig<m~vO#FEq{ZlyUn6Ww)1H|M1$79~~2XXcfp N79}Q^0F@R`005E=7YzUa delta 29 kcmbQhSi;q|yeze-I5R(wc_No7$FhRN(&E&VLlgaU0ixy$6aWAK diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 9e7b42e758c8b13c13533cea71f791d47f05ebaf..09c0b91be56c29b1c7daa9285c6c18e362d4162d 100644 GIT binary patch delta 60 zcmeBVS<1rIzPv28s5mn}k7Xj4IseL%%;fC&qQsKaC2plTIU79>Fp6%@OHC|Fs*KOf PD@iR%OfCVcEuH`Xy)YN{ delta 31 ncmZ3=(#gWrw!AF0s5mn}k9i`OImfbs#M0u_ltUW>4ln`$wtox# diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 17032fe868d170255a6ea33510c4e1e712049219..1a3d25c12173e9b0ef6bf1d43369dce941c4c790 100644 GIT binary patch delta 59 zcmcb{_=%CLeR)}GQE_H|9?L{7Q~s4DnaSDlMTsS;OWaCxawfX>i*C+KO)N^PjL*y~ ONi9lDE&(bno&W&H@)$G# delta 30 mcmeywc#V;(ZFyO0QE_H|9`i&lQ;uZ?iKWG<DTgNd^#cI9oeVnw diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 8602106d9fb75c346a88ed0c095686a054d3bf89..8c2e9993766a6b96fbcf719d537c09ef195686b2 100644 GIT binary patch delta 59 zcmcb~_?3~XeR)}GQE_H|9?L{7Q~s4DnaSDlMTsS;OWaCxawfV@65X7anpl)n8K0R~ Ol3J9QTmn>DJOKd7lNdb! delta 30 mcmey$c$1N<ZFyO0QE_H|9`i&lQ;uZ?iKWG<DTgNdO#%SBP7Fi< diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2d62cbcd8470320a087c24e1f41669e28f883087..b63af93c9026300b8617d4fabc51cdc278dcf82d 100644 GIT binary patch delta 59 zcmdnZbe)N-eR)}GQE_H|9?L{7Oa7H5naSDlMTsS;OWaCxawd9z5Z#=Ynpl)n8K0R~ Ol3J9QTmn>DJOKd3=NM@K delta 30 mcmcc4w3~^mZFyO0QE_H|9`i&lOO9m)iKWG<DTgKoeE<NpR19zc diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 8e3267879..2beee789e 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -102,7 +102,6 @@ pub fn generate_snapshot( day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused(), tick_rate: time.tick_rate, }; @@ -214,7 +213,8 @@ impl Plugin for BridgePlugin { crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), send_bridge_snapshot - .after(crate::perception::observer::compute_observer_snapshot), + .after(crate::perception::observer::compute_observer_snapshot) + .after(crate::simulation::interaction::compute_nearby_interactions), ), ); tracing::debug!("BridgePlugin initialized"); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index cbebfe2ac..f73637c6d 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -32,7 +32,6 @@ pub struct ObserverSnapshot { pub visible_tiles: Vec<VisibleTile>, /// Entities within interaction range with available verbs (D-060, #404). /// Sorted by distance (nearest first). v0.1 client reads verbs[0] on the nearest entity. - #[serde(default)] pub nearby_interactions: Vec<NearbyInteraction>, } @@ -46,10 +45,7 @@ pub struct GameTime { pub time_of_day: u64, /// Current day phase (Morning/Afternoon/Evening/Night) pub day_phase: DayPhase, - /// Whether simulation is paused (tick_rate == Paused) - pub paused: bool, - /// Current tick rate state (D-052) - #[serde(default)] + /// Current tick rate state (D-052). Client derives paused from TickRate::Paused. pub tick_rate: TickRate, } @@ -156,8 +152,8 @@ pub struct NearbyInteraction { pub entity_id: u64, /// Entity type for client-side verb display pub entity_type: EntityKind, - /// Manhattan distance from player - pub distance: f32, + /// Manhattan distance from player (integer tiles) + pub distance: u32, /// Available verbs sorted by priority (index 0 = highest priority) pub verbs: Vec<VerbOption>, } diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index ea102ae66..440ba6564 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -79,14 +79,16 @@ pub fn process_knowledge_events( if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity(stable_id, position, event.tick); } else { - tracing::warn!("DirectObservation target {:?} not in EntityRegistry", target); + debug_assert!(false, "DirectObservation target {:?} not in EntityRegistry", target); + tracing::error!("DirectObservation target {:?} not in EntityRegistry", target); } } KnowledgeEventType::LeftLOS { target } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity_leaving_los(&stable_id, event.tick); } else { - tracing::warn!("LeftLOS target {:?} not in EntityRegistry", target); + debug_assert!(false, "LeftLOS target {:?} not in EntityRegistry", target); + tracing::error!("LeftLOS target {:?} not in EntityRegistry", target); } } } diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index f77474c21..252d7a2ca 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -110,39 +110,39 @@ pub fn generate_observation_events( continue; } - let entity = Entity::from_bits(visible.entity_id); + // Convert wire StableId back to bevy Entity via registry + let stable_id = StableId(visible.entity_id); + let Some(entity) = registry.to_entity(&stable_id) else { + continue; + }; // Check if this is a new entity (not in observer's knowledge graph) - if let Some(stable_id) = registry.to_stable(entity) { - if !observer_kg.knows_entity(&stable_id) { - // Reconstruct tile position from render coords - let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z); - event_queue.push(ObservationEvent { - tick: time.tick, - trigger: ObservationTrigger::NewEntity { - entity: stable_id, - location: tile_pos, - }, - observer: observer_entity, - }); - } + if !observer_kg.knows_entity(&stable_id) { + // Reconstruct tile position from render coords + let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z); + event_queue.push(ObservationEvent { + tick: time.tick, + trigger: ObservationTrigger::NewEntity { + entity: stable_id, + location: tile_pos, + }, + observer: observer_entity, + }); } // Check routine deviation: visible NPC not at expected location if let Ok((actual_pos, routine)) = npc_query.get(entity) { if let Some(expected_pos) = routine.expected_location(current_phase) { if *actual_pos != expected_pos { - if let Some(stable_id) = registry.to_stable(entity) { - event_queue.push(ObservationEvent { - tick: time.tick, - trigger: ObservationTrigger::RoutineDeviation { - npc: stable_id, - expected: expected_pos, - actual: *actual_pos, - }, - observer: observer_entity, - }); - } + event_queue.push(ObservationEvent { + tick: time.tick, + trigger: ObservationTrigger::RoutineDeviation { + npc: stable_id, + expected: expected_pos, + actual: *actual_pos, + }, + observer: observer_entity, + }); } } } @@ -157,8 +157,8 @@ pub fn generate_observation_events( continue; }; - // Skip if currently visible - if visible_npc_bits.contains(&entity.to_bits()) { + // Skip if currently visible (visible_npc_bits contains wire StableId values) + if visible_npc_bits.contains(&stable_id.0) { continue; } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index ee5fa9d85..05b63f255 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -33,8 +33,8 @@ pub fn emit_observation_events( return; }; - // Collect visible entity IDs (Vec — linear search is faster at 5-20 entities) - let visible_entity_ids: Vec<u64> = snapshot + // Collect visible stable IDs (wire entity_id is now StableId, not Entity::to_bits()) + let visible_stable_ids: Vec<u64> = snapshot .entities .iter() .filter(|e| !matches!(e.kind, EntityKind::Player)) @@ -47,8 +47,11 @@ pub fn emit_observation_events( continue; } - // Look up the bevy Entity from the entity_id (which is Entity::to_bits()) - let entity = Entity::from_bits(visible.entity_id); + // Convert wire StableId back to bevy Entity via registry + let stable_id = crate::knowledge::types::StableId(visible.entity_id); + let Some(entity) = registry.to_entity(&stable_id) else { + continue; + }; // Get tile position for knowledge tracking if let Ok(pos) = entity_positions.get(entity) { @@ -69,10 +72,9 @@ pub fn emit_observation_events( continue; } - // Check if this entity is still visible in the current snapshot - if let Some(entity) = registry.to_entity(stable_id) { - let entity_bits = entity.to_bits(); - if !visible_entity_ids.contains(&entity_bits) { + // Check if this entity's StableId is still visible in the current snapshot + if !visible_stable_ids.contains(&stable_id.0) { + if let Some(entity) = registry.to_entity(stable_id) { event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index bbee9457a..9e21cf567 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -23,7 +23,7 @@ pub fn compute_observer_snapshot( time: Res<SimulationTime>, walkability: Res<WalkabilityMap>, registry: Res<EntityRegistry>, - interaction_buffer: Res<NearbyInteractionBuffer>, + mut interaction_buffer: ResMut<NearbyInteractionBuffer>, observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>, all_entities: Query<( Entity, @@ -115,9 +115,13 @@ pub fn compute_observer_snapshot( RelationshipState::Unknown }; - visible_entity_bits.insert(entity.to_bits()); + let wire_id = registry + .to_stable(entity) + .map(|sid| sid.0) + .unwrap_or_else(|| entity.to_bits()); + visible_entity_bits.insert(wire_id); entities.push(VisibleEntity { - entity_id: entity.to_bits(), + entity_id: wire_id, x: rx, y: ry, z: rz, @@ -129,65 +133,20 @@ pub fn compute_observer_snapshot( } // Step 6: Add remembered entities from knowledge graph (#366) - // Entities the observer knows about but can't currently see. - for (stable_id, knowledge) in observer_kg.known_entities_iter() { - // Skip if currently visible (already in the entity list) - if let Some(entity) = registry.to_entity(stable_id) { - if visible_entity_bits.contains(&entity.to_bits()) { - continue; - } - } - - // Skip if no known position (never directly observed) - let Some(position) = knowledge.last_known_position else { - continue; - }; - - // Skip if remembered position is on a different z-level than the observer - if position.z != z { - continue; - } - - // Skip if the remembered tile is currently visible — if the player - // can see the tile and the entity isn't there, don't show a ghost. - if visible_positions.contains(&(position.x, position.y)) { - continue; - } - - // Direct confidence means the entity should be in LOS — if it isn't, - // that's a transient data inconsistency. Skip rather than show a ghost. - if knowledge.confidence == KnowledgeConfidence::Direct { - continue; - } - - let (rx, ry, rz) = position.to_render_coords(); - let age_ticks = time.tick.saturating_sub(knowledge.last_observed_tick); - let entity_id = registry - .to_entity(stable_id) - .map(|e| e.to_bits()) - .unwrap_or(stable_id.0); - - entities.push(VisibleEntity { - entity_id, - x: rx, - y: ry, - z: rz, - kind: EntityKind::Npc, // Remembered entities are NPCs (only NPCs are tracked) - visibility: VisibilitySector::Forward, // Not meaningful for remembered entities - relationship: knowledge.relationship, - observation: EntityVisibility::Remembered { - confidence: knowledge.confidence, - age_ticks, - }, - }); - } + collect_remembered_entities( + observer_kg, + &visible_entity_bits, + &visible_positions, + z, + time.tick, + &mut entities, + ); // Step 7: Build GameTime from SimulationTime let game_time = GameTime { day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused(), tick_rate: time.tick_rate, }; @@ -207,10 +166,70 @@ pub fn compute_observer_snapshot( player_facing: facing, entities, visible_tiles, - nearby_interactions: interaction_buffer.interactions.clone(), + nearby_interactions: interaction_buffer.take(), }); } +/// Collect remembered entities from the knowledge graph — entities the observer +/// knows about but can't currently see. Filters out: already-visible entities, +/// entities without known positions, wrong z-level, visible-tile ghosts, and +/// transient Direct-confidence inconsistencies. +fn collect_remembered_entities( + observer_kg: &KnowledgeGraph, + visible_ids: &HashSet<u64>, + visible_positions: &HashSet<(i32, i32)>, + observer_z: i32, + current_tick: u64, + entities: &mut Vec<VisibleEntity>, +) { + for (stable_id, knowledge) in observer_kg.known_entities_iter() { + if visible_ids.contains(&stable_id.0) { + continue; + } + + let Some(position) = knowledge.last_known_position else { + continue; + }; + + if position.z != observer_z { + continue; + } + + // Tile is visible but entity isn't there — player knows it moved + if visible_positions.contains(&(position.x, position.y)) { + continue; + } + + // Direct confidence = should be in LOS; skip transient inconsistency + if knowledge.confidence == KnowledgeConfidence::Direct { + continue; + } + + let (rx, ry, rz) = position.to_render_coords(); + debug_assert!( + knowledge.last_observed_tick <= current_tick, + "last_observed_tick {} > current tick {}", + knowledge.last_observed_tick, + current_tick, + ); + let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick); + + entities.push(VisibleEntity { + entity_id: stable_id.0, + x: rx, + y: ry, + z: rz, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: knowledge.relationship, + observation: EntityVisibility::Remembered { + confidence: knowledge.confidence, + age_ticks, + }, + }); + } +} + #[cfg(test)] mod tests { use super::*; @@ -386,7 +405,7 @@ mod tests { snapshot.game_time.day_phase, crate::simulation::time::DayPhase::Evening ); - assert!(snapshot.game_time.paused); + assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); } #[test] diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 72bf3f278..25488cfba 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -102,17 +102,16 @@ fn apply_move( dx: i32, dy: i32, ) { - if let Ok((entity, pos)) = player_query.single() { - commands.entity(entity).insert(MoveIntent { - target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), - }); - // Update facing direction based on movement (D-015 vision cone) - commands - .entity(entity) - .insert(Facing(facing_from_delta(dx, dy))); - } else { - tracing::warn!("No player entity found for movement input"); - } + let (entity, pos) = player_query + .single() + .expect("PlayerCharacter entity must exist when processing input"); + commands.entity(entity).insert(MoveIntent { + target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), + }); + // Update facing direction based on movement (D-015 vision cone) + commands + .entity(entity) + .insert(Facing(facing_from_delta(dx, dy))); } #[cfg(test)] @@ -220,7 +219,8 @@ mod tests { } #[test] - fn process_input_no_player_no_panic() { + #[should_panic(expected = "PlayerCharacter entity must exist")] + fn process_input_no_player_panics() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); @@ -232,7 +232,6 @@ mod tests { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); - // Should not panic schedule.run(&mut world); } diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index d29327519..f304eae87 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -11,8 +11,8 @@ use crate::npc::Npc; use crate::simulation::movement::{PlayerCharacter, TilePosition}; /// Interaction range thresholds (Manhattan distance, same z-level) -pub const CLOSE_RANGE: u32 = 2; -pub const MID_RANGE: u32 = 5; +pub(crate) const CLOSE_RANGE: u32 = 2; +pub(crate) const MID_RANGE: u32 = 5; /// Component marking an entity as having available interactions. /// Attached to NPCs and examinable objects by the world setup or content loader. @@ -124,13 +124,18 @@ pub fn compute_nearby_interactions( continue; } - // Sort by priority (lower number = higher priority) - verbs.sort_by_key(|v| v.priority); + // Sort by priority (lower = higher), then by kind discriminant for stability + verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + + let wire_id = registry + .to_stable(entity) + .map(|sid| sid.0) + .unwrap_or_else(|| entity.to_bits()); buffer.interactions.push(NearbyInteraction { - entity_id: entity.to_bits(), + entity_id: wire_id, entity_type, - distance: distance as f32, + distance, verbs, }); } @@ -138,13 +143,22 @@ pub fn compute_nearby_interactions( // Sort interactions by distance (nearest first) buffer .interactions - .sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap()); + .sort_by_key(|a| a.distance); } -/// Buffer for nearby interaction results, consumed by snapshot generation +/// Buffer for nearby interaction results, consumed by snapshot generation. +/// Field is private — use `take()` to drain results into the snapshot. #[derive(Resource, Debug, Default)] pub struct NearbyInteractionBuffer { - pub interactions: Vec<NearbyInteraction>, + interactions: Vec<NearbyInteraction>, +} + +impl NearbyInteractionBuffer { + /// Drain and return interactions, leaving the buffer empty. + /// Avoids cloning per-frame; snapshot owns the Vec after take. + pub fn take(&mut self) -> Vec<NearbyInteraction> { + std::mem::take(&mut self.interactions) + } } #[cfg(test)] @@ -329,4 +343,54 @@ mod tests { let buffer = world.resource::<NearbyInteractionBuffer>(); assert!(buffer.interactions.is_empty()); } + + #[test] + fn poi_npc_at_mid_range_gets_observe_only() { + // POI priority flip only applies at close range — mid range always Observe-only + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((Npc, TilePosition::new(5, 9, 0), Interactable)) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + } + + #[test] + fn equidistant_npcs_sorted_deterministically() { + let mut world = setup_world(); + // Two NPCs at equal distance (1 tile each) + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable)); + world.spawn((Npc, TilePosition::new(4, 5, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::<NearbyInteractionBuffer>(); + assert_eq!(buffer.interactions.len(), 2); + // Both at distance 1 — order should be stable across runs + assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance); + } } diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index dd80aad2f..76d9dc554 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -92,6 +92,11 @@ impl SimulationTime { /// Advance the simulation tick based on current tick rate. /// Full: +1 every frame. Half: +1 every 2 frames. Paused: no advance. +/// +/// Uses fractional accumulation: each frame adds `tick_rate.scale()` to an +/// internal accumulator. When it reaches 1.0, a tick fires and the accumulator +/// subtracts 1.0. This ensures Half rate produces exactly N/2 ticks over N +/// frames with no floating-point drift (0.5 is exactly representable in f32). pub fn advance_tick(mut time: ResMut<SimulationTime>) { let scale = time.tick_rate.scale(); if scale <= 0.0 { @@ -193,4 +198,19 @@ mod tests { let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() }; assert_eq!(time.day(), 3); } + + #[test] + fn half_rate_no_drift_over_10000_frames() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(advance_tick); + + for _ in 0..10_000 { + schedule.run(&mut world); + } + + // 10,000 frames at Half rate (0.5) should yield exactly 5,000 ticks + assert_eq!(world.resource::<SimulationTime>().tick, 5_000); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index b2e8648ba..43bc305cf 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -39,7 +39,6 @@ fn snapshot_roundtrip_over_unix_socket() { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, - paused: false, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index b3bd68877..1df011156 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -25,7 +25,6 @@ fn snapshot_roundtrip_over_tcp() { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, - paused: false, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 5f60df679..a8bf8b52f 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -68,7 +68,7 @@ fn player_moves_north_through_full_pipeline() { // v2 fields populated assert_eq!(snapshot.game_time.day, 0); assert_eq!(snapshot.game_time.day_phase, settled_reach_server::simulation::time::DayPhase::Morning); - assert!(!snapshot.game_time.paused); + assert_eq!(snapshot.game_time.tick_rate, settled_reach_server::simulation::time::TickRate::Full); let player_entity = &snapshot.entities[0]; // Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0) diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 7ee1ef36d..3ba0d4f78 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -24,7 +24,6 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot day: 0, time_of_day: 0, day_phase: DayPhase::Morning, - paused: false, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, @@ -158,7 +157,6 @@ fn generate_msgpack_fixtures() { day: 1, time_of_day: 720, day_phase: DayPhase::Evening, - paused: false, tick_rate: TickRate::Full, }, player_facing: FacingDirection::Southeast, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index e308963ff..80fbfb819 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -13,7 +13,6 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, - paused: false, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, @@ -127,8 +126,9 @@ fn all_fixtures_deserialize() { let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name)); if name.starts_with("snapshot") { - rmp_serde::from_slice::<ObserverSnapshot>(&bytes) + let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes) .unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e)); + assert_eq!(snap.version, 4, "fixture {} has wrong version", name); } else if name.starts_with("input_batch") { rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); @@ -186,7 +186,6 @@ fn snapshot_v2_fields_roundtrip() { day: 3, time_of_day: 720, day_phase: DayPhase::Evening, - paused: true, tick_rate: TickRate::Paused, }, player_facing: FacingDirection::Southeast, @@ -224,7 +223,7 @@ fn snapshot_v2_fields_roundtrip() { assert_eq!(decoded.game_time.day, 3); assert_eq!(decoded.game_time.time_of_day, 720); assert_eq!(decoded.game_time.day_phase, DayPhase::Evening); - assert!(decoded.game_time.paused); + assert_eq!(decoded.game_time.tick_rate, TickRate::Paused); assert_eq!(decoded.player_facing, FacingDirection::Southeast); assert_eq!(decoded.visible_tiles.len(), 2); assert_eq!(decoded.visible_tiles[0].visibility, VisibilitySector::Forward); @@ -254,7 +253,6 @@ fn all_facing_direction_variants_roundtrip() { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, - paused: false, tick_rate: TickRate::Full, }, player_facing: dir, From ece4dfe1c4b6a8169c55e9697b0a9a6690e91903 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 20:14:41 +0100 Subject: [PATCH 08/21] fix(data): restore hierarchical fields in district metadata Re-added system/station/district fields to district.yaml that were incorrectly removed during Tyre10 cleanup. These carry hierarchical context (planet, station), not redundant identity. Made canonical_id optional in schema since it's derived from directory path at load time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- content/_schema/district.schema.json | 4 ++-- content/districts/sova-transit/district.yaml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/content/_schema/district.schema.json b/content/_schema/district.schema.json index 564540542..43bf67a5e 100644 --- a/content/_schema/district.schema.json +++ b/content/_schema/district.schema.json @@ -4,13 +4,13 @@ "title": "District Metadata", "description": "District definition file — one per district directory (D-036).", "type": "object", - "required": ["canonical_id", "display_name", "system", "station", "district", "description", "locations", "npc_count"], + "required": ["display_name", "system", "station", "district", "description", "locations", "npc_count"], "additionalProperties": false, "properties": { "canonical_id": { "type": "string", "pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+$", - "description": "Canonical ID in format {system}.{station}.{district}" + "description": "Optional — derived from directory path at load time. If present, must match {system}.{station}.{district}." }, "display_name": { "type": "string", diff --git a/content/districts/sova-transit/district.yaml b/content/districts/sova-transit/district.yaml index a56bd83b4..d9654c10f 100644 --- a/content/districts/sova-transit/district.yaml +++ b/content/districts/sova-transit/district.yaml @@ -1,5 +1,6 @@ # Sova Transit District metadata (D-036) -canonical_id: "krenn.sova.transit" +# Source of truth for identity: directory path (districts/sova-transit/). +# canonical_id is derived from the path at load time by ContentValidator. display_name: "Sova Transit District" system: "krenn" station: "sova" From 8cd7ef21ae378b6442a55c95b932d48110b482de Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 20:14:56 +0100 Subject: [PATCH 09/21] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ba6e979..57b05e247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions - Content schema definitions (#386) — 8 JSON Schema files (draft 2020-12) for district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, fact-catalog validation +### Fixed +- Wire entity_id now uses StableId consistently across observer, observation, interpretation, and interaction systems (was Entity::to_bits() in some paths) +- Restored system/station/district hierarchical fields in district metadata (incorrectly removed during canonical_id cleanup) + ### Changed -- ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field with serde default for backward compatibility +- ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field, tick_rate replaces paused field in GameTime +- NearbyInteractionBuffer.interactions is now private with take() accessor (no per-frame clone) +- NearbyInteraction.distance changed from f32 to u32 (matches manhattan distance) +- Missing PlayerCharacter in input processing now panics instead of silent no-op +- Verb sort uses (priority, kind) tuple for deterministic ordering at equal priority +- Unregistered entity in knowledge events triggers debug_assert + error (was warn) +- District schema: canonical_id is now optional (derived from directory path at load time) - Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them - Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure From 9d53869ca780878f123f40dcef9932ae1bec766a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 21:22:23 +0100 Subject: [PATCH 10/21] refactor(data): restructure content directory for galaxy scale Replace flat content/districts/ with hierarchical campaign/system/ station/district structure. Path mirrors canonical IDs, enables glob-based discovery, and is multi-campaign/DLC ready. - git mv 46 files preserving history - New metadata: campaign.yaml, system.yaml, station.yaml - Rewrite content.yaml with glob-based district discovery - Add campaign, system, station JSON schemas - Update district schema: hierarchy fields derived from path - Update npc-profile schema: accept district-scoped IDs - Add TODO to 17 dialogue/monologue pool files for generator revision - Update _meta/README.md with new hierarchy documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- content/_meta/README.md | 44 +++++++++++++++++++ content/_schema/campaign.schema.json | 22 ++++++++++ content/_schema/district.schema.json | 15 ++++--- content/_schema/npc-profile.schema.json | 6 +-- content/_schema/station.schema.json | 26 +++++++++++ content/_schema/system.schema.json | 26 +++++++++++ content/campaigns/main/campaign.yaml | 7 +++ .../maintenance-corridors/ring-operative.yaml | 2 + .../dialogue/the-last-shift/bar-owner.yaml | 2 + .../dialogue/the-last-shift/bar-regular.yaml | 2 + .../dialogue/the-last-shift/bartender.yaml | 2 + .../dialogue/the-terminal/courier.yaml | 2 + .../dialogue/the-terminal/dock-worker.yaml | 2 + .../dialogue/the-terminal/new-hire.yaml | 2 + .../dialogue/the-terminal/scheduler.yaml | 2 + .../the-terminal/shift-supervisor.yaml | 2 + .../sova/districts/transit}/district.yaml | 7 +-- .../locations/maintenance-corridors.yaml | 0 .../transit}/locations/the-last-shift.yaml | 0 .../transit}/locations/the-terminal.yaml | 0 .../transit/monologue/detective/general.yaml | 2 + .../detective/maintenance-corridors.yaml | 2 + .../monologue/detective/the-last-shift.yaml | 2 + .../monologue/detective/the-terminal.yaml | 2 + .../transit/monologue/smuggler/general.yaml | 2 + .../smuggler/maintenance-corridors.yaml | 2 + .../monologue/smuggler/the-last-shift.yaml | 2 + .../monologue/smuggler/the-terminal.yaml | 2 + .../sova/districts/transit}/npcs/devra.yaml | 0 .../sova/districts/transit}/npcs/drin.yaml | 0 .../sova/districts/transit}/npcs/harek.yaml | 0 .../districts/transit}/npcs/kael-davan.yaml | 0 .../districts/transit}/npcs/lera-sessik.yaml | 0 .../districts/transit}/npcs/maret-korr.yaml | 0 .../districts/transit}/npcs/naia-tamm.yaml | 0 .../sova/districts/transit}/npcs/olin.yaml | 0 .../sova/districts/transit}/npcs/pell.yaml | 0 .../sova/districts/transit}/npcs/renn.yaml | 0 .../sova/districts/transit}/npcs/resha.yaml | 0 .../sova/districts/transit}/npcs/sabel.yaml | 0 .../districts/transit}/npcs/sera-venn.yaml | 0 .../sova/districts/transit}/npcs/sess.yaml | 0 .../sova/districts/transit}/npcs/tav.yaml | 0 .../districts/transit}/npcs/torek-lintar.yaml | 0 .../sova/districts/transit}/npcs/voss.yaml | 0 .../transit}/routines/schedules.yaml | 0 .../districts/transit}/templates/.gitkeep | 0 .../transit}/triangles/bar-tensions.yaml | 0 .../transit}/triangles/hub-power.yaml | 0 .../triangles/informant-question.yaml | 0 .../transit}/triangles/worried-knowledge.yaml | 0 .../transit}/triangles/worried-partner.yaml | 0 .../systems/krenn/stations/sova/station.yaml | 7 +++ .../campaigns/main/systems/krenn/system.yaml | 8 ++++ content/content.yaml | 12 +++-- .../maintenance-corridors/ring-operative.yaml | 1 - .../dialogue/the-last-shift/bar-owner.yaml | 1 - .../dialogue/the-last-shift/bar-regular.yaml | 1 - .../dialogue/the-last-shift/bartender.yaml | 1 - .../dialogue/the-terminal/courier.yaml | 1 - .../dialogue/the-terminal/dock-worker.yaml | 1 - .../dialogue/the-terminal/new-hire.yaml | 1 - .../dialogue/the-terminal/scheduler.yaml | 1 - .../the-terminal/shift-supervisor.yaml | 1 - .../monologue/detective/general.yaml | 1 - .../detective/maintenance-corridors.yaml | 1 - .../monologue/detective/the-last-shift.yaml | 1 - .../monologue/detective/the-terminal.yaml | 1 - .../monologue/smuggler/general.yaml | 1 - .../smuggler/maintenance-corridors.yaml | 1 - .../monologue/smuggler/the-last-shift.yaml | 1 - .../monologue/smuggler/the-terminal.yaml | 1 - content/global/regions/krenn.yaml | 1 - 73 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 content/_schema/campaign.schema.json create mode 100644 content/_schema/station.schema.json create mode 100644 content/_schema/system.schema.json create mode 100644 content/campaigns/main/campaign.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/maintenance-corridors/ring-operative.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-owner.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-regular.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bartender.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/courier.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/dock-worker.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/new-hire.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/scheduler.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/shift-supervisor.yaml rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/district.yaml (65%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/locations/maintenance-corridors.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/locations/the-last-shift.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/locations/the-terminal.yaml (100%) create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/general.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/maintenance-corridors.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-last-shift.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/general.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/maintenance-corridors.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-last-shift.yaml create mode 100644 content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/devra.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/drin.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/harek.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/kael-davan.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/lera-sessik.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/maret-korr.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/naia-tamm.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/olin.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/pell.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/renn.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/resha.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/sabel.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/sera-venn.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/sess.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/tav.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/torek-lintar.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/npcs/voss.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/routines/schedules.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/templates/.gitkeep (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/triangles/bar-tensions.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/triangles/hub-power.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/triangles/informant-question.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/triangles/worried-knowledge.yaml (100%) rename content/{districts/sova-transit => campaigns/main/systems/krenn/stations/sova/districts/transit}/triangles/worried-partner.yaml (100%) create mode 100644 content/campaigns/main/systems/krenn/stations/sova/station.yaml create mode 100644 content/campaigns/main/systems/krenn/system.yaml delete mode 100644 content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-terminal/courier.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml delete mode 100644 content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml delete mode 100644 content/districts/sova-transit/monologue/detective/general.yaml delete mode 100644 content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml delete mode 100644 content/districts/sova-transit/monologue/detective/the-last-shift.yaml delete mode 100644 content/districts/sova-transit/monologue/detective/the-terminal.yaml delete mode 100644 content/districts/sova-transit/monologue/smuggler/general.yaml delete mode 100644 content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml delete mode 100644 content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml delete mode 100644 content/districts/sova-transit/monologue/smuggler/the-terminal.yaml delete mode 100644 content/global/regions/krenn.yaml diff --git a/content/_meta/README.md b/content/_meta/README.md index 0800a891a..1412d8343 100644 --- a/content/_meta/README.md +++ b/content/_meta/README.md @@ -4,3 +4,47 @@ Directories prefixed with `_` are infrastructure, not game content. The server c - `_meta/` — Infrastructure metadata (this directory) - `_schema/` — JSON Schema validation files (draft 2020-12) + +## Directory Hierarchy + +Content is organized hierarchically to match the game universe: + +``` +content/ + content.yaml # Manifest: campaign list, glob discovery patterns + global/ # Cross-campaign shared content (factions, enums, knowledge) + campaigns/ + {campaign}/ # e.g., "main" + campaign.yaml + systems/ + {system}/ # e.g., "krenn" + system.yaml + stations/ + {station}/ # e.g., "sova" + station.yaml + districts/ + {district}/ # e.g., "transit" + district.yaml + npcs/ + locations/ + triangles/ + dialogue/ + monologue/ + routines/ + templates/ +``` + +## Canonical ID Derivation + +Identity is derived from directory path at load time — no redundant ID fields in YAML. + +- **District:** `{system}.{station}.{district}` (e.g., `krenn.sova.transit`) +- **NPC (within district):** `npc:{slug}` (e.g., `npc:kael-davan`) +- **NPC (cross-district):** `npc:{district}.{slug}` (e.g., `npc:transit.kael-davan`) +- **Location:** `{system}.{station}.{district}.location.{slug}` + +## Content Discovery + +The loader reads `content.yaml` for enabled campaigns and their glob patterns. +District discovery uses `systems/**/districts/*/district.yaml` — no per-district +manifest entry needed. Adding a district = creating a directory with district.yaml. diff --git a/content/_schema/campaign.schema.json b/content/_schema/campaign.schema.json new file mode 100644 index 000000000..37d54c93c --- /dev/null +++ b/content/_schema/campaign.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "campaign.schema.json", + "title": "Campaign Metadata", + "description": "Campaign definition file — one per campaign directory (D-003).", + "type": "object", + "required": ["display_name", "description"], + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } +} diff --git a/content/_schema/district.schema.json b/content/_schema/district.schema.json index 43bf67a5e..c5ba67c71 100644 --- a/content/_schema/district.schema.json +++ b/content/_schema/district.schema.json @@ -2,15 +2,15 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "district.schema.json", "title": "District Metadata", - "description": "District definition file — one per district directory (D-036).", + "description": "District definition file — one per district directory (D-036). Identity derived from directory path.", "type": "object", - "required": ["display_name", "system", "station", "district", "description", "locations", "npc_count"], + "required": ["display_name", "description", "locations", "npc_count"], "additionalProperties": false, "properties": { "canonical_id": { "type": "string", "pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+$", - "description": "Optional — derived from directory path at load time. If present, must match {system}.{station}.{district}." + "description": "Deprecated — derived from directory path at load time. If present, must match {system}.{station}.{district}." }, "display_name": { "type": "string", @@ -18,15 +18,18 @@ }, "system": { "type": "string", - "pattern": "^[a-z]+$" + "pattern": "^[a-z]+$", + "description": "Deprecated — derived from directory path." }, "station": { "type": "string", - "pattern": "^[a-z]+$" + "pattern": "^[a-z]+$", + "description": "Deprecated — derived from directory path." }, "district": { "type": "string", - "pattern": "^[a-z-]+$" + "pattern": "^[a-z-]+$", + "description": "Deprecated — derived from directory path." }, "description": { "type": "string", diff --git a/content/_schema/npc-profile.schema.json b/content/_schema/npc-profile.schema.json index 584135461..5b23c3a97 100644 --- a/content/_schema/npc-profile.schema.json +++ b/content/_schema/npc-profile.schema.json @@ -9,8 +9,8 @@ "properties": { "canonical_id": { "type": "string", - "pattern": "^npc:[a-z][a-z0-9-]*$", - "description": "Short-form canonical ID: npc:{slug}" + "pattern": "^npc:([a-z][a-z0-9-]+\\.)?[a-z][a-z0-9-]*$", + "description": "Canonical ID: npc:{slug} (within district) or npc:{district}.{slug} (cross-district)" }, "display_name": { "type": "string", @@ -167,7 +167,7 @@ "properties": { "target": { "type": "string", - "pattern": "^npc:[a-z][a-z0-9-]*$" + "pattern": "^npc:([a-z][a-z0-9-]+\\.)?[a-z][a-z0-9-]*$" }, "kind": { "type": "string", diff --git a/content/_schema/station.schema.json b/content/_schema/station.schema.json new file mode 100644 index 000000000..63f91bd2a --- /dev/null +++ b/content/_schema/station.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "station.schema.json", + "title": "Station Metadata", + "description": "Station definition file — one per station directory (D-036).", + "type": "object", + "required": ["display_name"], + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "districts": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "uniqueItems": true + } + } +} diff --git a/content/_schema/system.schema.json b/content/_schema/system.schema.json new file mode 100644 index 000000000..5733ab01e --- /dev/null +++ b/content/_schema/system.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "system.schema.json", + "title": "Star System Metadata", + "description": "System definition file — one per system directory (D-036).", + "type": "object", + "required": ["display_name"], + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "stations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "uniqueItems": true + } + } +} diff --git a/content/campaigns/main/campaign.yaml b/content/campaigns/main/campaign.yaml new file mode 100644 index 000000000..580e41efd --- /dev/null +++ b/content/campaigns/main/campaign.yaml @@ -0,0 +1,7 @@ +# Main Campaign metadata (D-003) +display_name: "The Settled Reach" +description: > + The core campaign. Set in a universe of wormhole-connected star systems, + neural lattice technology, and faction-driven politics. Occlusion-based + detective game with combat elements. +version: "0.1.0" diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/maintenance-corridors/ring-operative.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/maintenance-corridors/ring-operative.yaml new file mode 100644 index 000000000..3dda7d312 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/maintenance-corridors/ring-operative.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: ring-operative at Maintenance Corridors diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-owner.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-owner.yaml new file mode 100644 index 000000000..88c87ec6d --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-owner.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: bar-owner at The Last Shift diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-regular.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-regular.yaml new file mode 100644 index 000000000..0de4ccb95 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bar-regular.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: bar-regular at The Last Shift diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bartender.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bartender.yaml new file mode 100644 index 000000000..446433be0 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-last-shift/bartender.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: bartender at The Last Shift diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/courier.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/courier.yaml new file mode 100644 index 000000000..fa38656a7 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/courier.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: courier at The Terminal diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/dock-worker.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/dock-worker.yaml new file mode 100644 index 000000000..91d1b6884 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/dock-worker.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: dock-worker at The Terminal diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/new-hire.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/new-hire.yaml new file mode 100644 index 000000000..acc580252 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/new-hire.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: new-hire at The Terminal diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/scheduler.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/scheduler.yaml new file mode 100644 index 000000000..06371e336 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/scheduler.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: scheduler at The Terminal diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/shift-supervisor.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/shift-supervisor.yaml new file mode 100644 index 000000000..3c5c35f79 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/dialogue/the-terminal/shift-supervisor.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Dialogue: shift-supervisor at The Terminal diff --git a/content/districts/sova-transit/district.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/district.yaml similarity index 65% rename from content/districts/sova-transit/district.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/district.yaml index d9654c10f..4aefb6f45 100644 --- a/content/districts/sova-transit/district.yaml +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/district.yaml @@ -1,10 +1,7 @@ # Sova Transit District metadata (D-036) -# Source of truth for identity: directory path (districts/sova-transit/). -# canonical_id is derived from the path at load time by ContentValidator. +# Identity derived from directory path: campaigns/main/systems/krenn/stations/sova/districts/transit/ +# canonical_id: krenn.sova.transit (derived at load time by ContentValidator) display_name: "Sova Transit District" -system: "krenn" -station: "sova" -district: "transit" description: > A 40-year-old prefab-modular-retrofitted freight logistics hub on Station Sova. Three social sites: The Terminal (logistics hub), The Last Shift (bar), diff --git a/content/districts/sova-transit/locations/maintenance-corridors.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/maintenance-corridors.yaml similarity index 100% rename from content/districts/sova-transit/locations/maintenance-corridors.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/maintenance-corridors.yaml diff --git a/content/districts/sova-transit/locations/the-last-shift.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-last-shift.yaml similarity index 100% rename from content/districts/sova-transit/locations/the-last-shift.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-last-shift.yaml diff --git a/content/districts/sova-transit/locations/the-terminal.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-terminal.yaml similarity index 100% rename from content/districts/sova-transit/locations/the-terminal.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-terminal.yaml diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/general.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/general.yaml new file mode 100644 index 000000000..d8df251ae --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/general.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: detective at general diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/maintenance-corridors.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/maintenance-corridors.yaml new file mode 100644 index 000000000..3c7758eb9 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/maintenance-corridors.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: detective at maintenance-corridors diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-last-shift.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-last-shift.yaml new file mode 100644 index 000000000..ef61e3f81 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-last-shift.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: detective at the-last-shift diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml new file mode 100644 index 000000000..d3d199329 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: detective at the-terminal diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/general.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/general.yaml new file mode 100644 index 000000000..46efbe223 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/general.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: smuggler at general diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/maintenance-corridors.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/maintenance-corridors.yaml new file mode 100644 index 000000000..b1bdb0778 --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/maintenance-corridors.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: smuggler at maintenance-corridors diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-last-shift.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-last-shift.yaml new file mode 100644 index 000000000..24304988d --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-last-shift.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: smuggler at the-last-shift diff --git a/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml new file mode 100644 index 000000000..9c7de827b --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml @@ -0,0 +1,2 @@ +# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2). +# Monologue: smuggler at the-terminal diff --git a/content/districts/sova-transit/npcs/devra.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/devra.yaml similarity index 100% rename from content/districts/sova-transit/npcs/devra.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/devra.yaml diff --git a/content/districts/sova-transit/npcs/drin.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/drin.yaml similarity index 100% rename from content/districts/sova-transit/npcs/drin.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/drin.yaml diff --git a/content/districts/sova-transit/npcs/harek.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/harek.yaml similarity index 100% rename from content/districts/sova-transit/npcs/harek.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/harek.yaml diff --git a/content/districts/sova-transit/npcs/kael-davan.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/kael-davan.yaml similarity index 100% rename from content/districts/sova-transit/npcs/kael-davan.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/kael-davan.yaml diff --git a/content/districts/sova-transit/npcs/lera-sessik.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/lera-sessik.yaml similarity index 100% rename from content/districts/sova-transit/npcs/lera-sessik.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/lera-sessik.yaml diff --git a/content/districts/sova-transit/npcs/maret-korr.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/maret-korr.yaml similarity index 100% rename from content/districts/sova-transit/npcs/maret-korr.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/maret-korr.yaml diff --git a/content/districts/sova-transit/npcs/naia-tamm.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/naia-tamm.yaml similarity index 100% rename from content/districts/sova-transit/npcs/naia-tamm.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/naia-tamm.yaml diff --git a/content/districts/sova-transit/npcs/olin.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/olin.yaml similarity index 100% rename from content/districts/sova-transit/npcs/olin.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/olin.yaml diff --git a/content/districts/sova-transit/npcs/pell.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/pell.yaml similarity index 100% rename from content/districts/sova-transit/npcs/pell.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/pell.yaml diff --git a/content/districts/sova-transit/npcs/renn.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/renn.yaml similarity index 100% rename from content/districts/sova-transit/npcs/renn.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/renn.yaml diff --git a/content/districts/sova-transit/npcs/resha.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/resha.yaml similarity index 100% rename from content/districts/sova-transit/npcs/resha.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/resha.yaml diff --git a/content/districts/sova-transit/npcs/sabel.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sabel.yaml similarity index 100% rename from content/districts/sova-transit/npcs/sabel.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sabel.yaml diff --git a/content/districts/sova-transit/npcs/sera-venn.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sera-venn.yaml similarity index 100% rename from content/districts/sova-transit/npcs/sera-venn.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sera-venn.yaml diff --git a/content/districts/sova-transit/npcs/sess.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sess.yaml similarity index 100% rename from content/districts/sova-transit/npcs/sess.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/sess.yaml diff --git a/content/districts/sova-transit/npcs/tav.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/tav.yaml similarity index 100% rename from content/districts/sova-transit/npcs/tav.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/tav.yaml diff --git a/content/districts/sova-transit/npcs/torek-lintar.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/torek-lintar.yaml similarity index 100% rename from content/districts/sova-transit/npcs/torek-lintar.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/torek-lintar.yaml diff --git a/content/districts/sova-transit/npcs/voss.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/voss.yaml similarity index 100% rename from content/districts/sova-transit/npcs/voss.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/npcs/voss.yaml diff --git a/content/districts/sova-transit/routines/schedules.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/routines/schedules.yaml similarity index 100% rename from content/districts/sova-transit/routines/schedules.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/routines/schedules.yaml diff --git a/content/districts/sova-transit/templates/.gitkeep b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/templates/.gitkeep similarity index 100% rename from content/districts/sova-transit/templates/.gitkeep rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/templates/.gitkeep diff --git a/content/districts/sova-transit/triangles/bar-tensions.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/bar-tensions.yaml similarity index 100% rename from content/districts/sova-transit/triangles/bar-tensions.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/bar-tensions.yaml diff --git a/content/districts/sova-transit/triangles/hub-power.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/hub-power.yaml similarity index 100% rename from content/districts/sova-transit/triangles/hub-power.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/hub-power.yaml diff --git a/content/districts/sova-transit/triangles/informant-question.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/informant-question.yaml similarity index 100% rename from content/districts/sova-transit/triangles/informant-question.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/informant-question.yaml diff --git a/content/districts/sova-transit/triangles/worried-knowledge.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml similarity index 100% rename from content/districts/sova-transit/triangles/worried-knowledge.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-knowledge.yaml diff --git a/content/districts/sova-transit/triangles/worried-partner.yaml b/content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml similarity index 100% rename from content/districts/sova-transit/triangles/worried-partner.yaml rename to content/campaigns/main/systems/krenn/stations/sova/districts/transit/triangles/worried-partner.yaml diff --git a/content/campaigns/main/systems/krenn/stations/sova/station.yaml b/content/campaigns/main/systems/krenn/stations/sova/station.yaml new file mode 100644 index 000000000..79acac6dc --- /dev/null +++ b/content/campaigns/main/systems/krenn/stations/sova/station.yaml @@ -0,0 +1,7 @@ +# Station Sova metadata (D-036) +display_name: "Station Sova" +description: > + A freight logistics station in the Krenn system. 40-year-old prefab-modular + facility serving the span gate's cargo throughput. ~12,000 population. +districts: + - "transit" diff --git a/content/campaigns/main/systems/krenn/system.yaml b/content/campaigns/main/systems/krenn/system.yaml new file mode 100644 index 000000000..2885563c4 --- /dev/null +++ b/content/campaigns/main/systems/krenn/system.yaml @@ -0,0 +1,8 @@ +# Krenn System metadata (D-036) +display_name: "Krenn System" +description: > + A mid-tier system connected via the Sova span gate. Industrial economy + centered on freight logistics and lattice component trade. ~2.4M population, + ~180 years settled. +stations: + - "sova" diff --git a/content/content.yaml b/content/content.yaml index a62b6031c..b8cf26b1a 100644 --- a/content/content.yaml +++ b/content/content.yaml @@ -1,7 +1,11 @@ # Content manifest — The Settled Reach v0.1 -# The server reads this first to discover enabled districts and load order. +# The server reads this first to discover campaigns and content layout. +# District discovery uses glob patterns — no per-district listing needed. version: "0.1.0" -districts: - - id: "sova-transit" - path: "districts/sova-transit" + +campaigns: + - id: "main" + path: "campaigns/main" enabled: true + discovery: + districts: "systems/**/districts/*/district.yaml" diff --git a/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml b/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml deleted file mode 100644 index a5cc92125..000000000 --- a/content/districts/sova-transit/dialogue/maintenance-corridors/ring-operative.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: ring-operative at Maintenance Corridors diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml deleted file mode 100644 index 565ae4882..000000000 --- a/content/districts/sova-transit/dialogue/the-last-shift/bar-owner.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: bar-owner at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml deleted file mode 100644 index 6f99a3176..000000000 --- a/content/districts/sova-transit/dialogue/the-last-shift/bar-regular.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: bar-regular at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml b/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml deleted file mode 100644 index 27b53f4a7..000000000 --- a/content/districts/sova-transit/dialogue/the-last-shift/bartender.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: bartender at The Last Shift diff --git a/content/districts/sova-transit/dialogue/the-terminal/courier.yaml b/content/districts/sova-transit/dialogue/the-terminal/courier.yaml deleted file mode 100644 index 8fdc9b9c7..000000000 --- a/content/districts/sova-transit/dialogue/the-terminal/courier.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: courier at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml b/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml deleted file mode 100644 index 66a6d81fe..000000000 --- a/content/districts/sova-transit/dialogue/the-terminal/dock-worker.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: dock-worker at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml b/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml deleted file mode 100644 index 4390a85c5..000000000 --- a/content/districts/sova-transit/dialogue/the-terminal/new-hire.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: new-hire at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml b/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml deleted file mode 100644 index 54dd25883..000000000 --- a/content/districts/sova-transit/dialogue/the-terminal/scheduler.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: scheduler at The Terminal diff --git a/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml b/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml deleted file mode 100644 index 1247ffcdd..000000000 --- a/content/districts/sova-transit/dialogue/the-terminal/shift-supervisor.yaml +++ /dev/null @@ -1 +0,0 @@ -# Dialogue: shift-supervisor at The Terminal diff --git a/content/districts/sova-transit/monologue/detective/general.yaml b/content/districts/sova-transit/monologue/detective/general.yaml deleted file mode 100644 index a1330ca50..000000000 --- a/content/districts/sova-transit/monologue/detective/general.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: detective at general diff --git a/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml b/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml deleted file mode 100644 index ce27cb434..000000000 --- a/content/districts/sova-transit/monologue/detective/maintenance-corridors.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: detective at maintenance-corridors diff --git a/content/districts/sova-transit/monologue/detective/the-last-shift.yaml b/content/districts/sova-transit/monologue/detective/the-last-shift.yaml deleted file mode 100644 index 2f422b2e6..000000000 --- a/content/districts/sova-transit/monologue/detective/the-last-shift.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: detective at the-last-shift diff --git a/content/districts/sova-transit/monologue/detective/the-terminal.yaml b/content/districts/sova-transit/monologue/detective/the-terminal.yaml deleted file mode 100644 index afbc4c886..000000000 --- a/content/districts/sova-transit/monologue/detective/the-terminal.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: detective at the-terminal diff --git a/content/districts/sova-transit/monologue/smuggler/general.yaml b/content/districts/sova-transit/monologue/smuggler/general.yaml deleted file mode 100644 index 786bbd0aa..000000000 --- a/content/districts/sova-transit/monologue/smuggler/general.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: smuggler at general diff --git a/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml b/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml deleted file mode 100644 index 5ee5f5e34..000000000 --- a/content/districts/sova-transit/monologue/smuggler/maintenance-corridors.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: smuggler at maintenance-corridors diff --git a/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml b/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml deleted file mode 100644 index ce327e9fc..000000000 --- a/content/districts/sova-transit/monologue/smuggler/the-last-shift.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: smuggler at the-last-shift diff --git a/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml b/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml deleted file mode 100644 index 68e377e93..000000000 --- a/content/districts/sova-transit/monologue/smuggler/the-terminal.yaml +++ /dev/null @@ -1 +0,0 @@ -# Monologue: smuggler at the-terminal diff --git a/content/global/regions/krenn.yaml b/content/global/regions/krenn.yaml deleted file mode 100644 index 689895b1a..000000000 --- a/content/global/regions/krenn.yaml +++ /dev/null @@ -1 +0,0 @@ -# Krenn System — regional metadata (D-036) From e2f2f4e590e7da30ca631e148ff465f74d27c4ca Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 21:22:28 +0100 Subject: [PATCH 11/21] refactor(server): remove dead generate_snapshot function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete generate_snapshot from bridge/mod.rs — superseded by perception::observer::compute_observer_snapshot. Not referenced in any schedule or test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/bridge/mod.rs | 58 ---------------------------------------- 1 file changed, 58 deletions(-) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 2beee789e..56e20a558 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -63,64 +63,6 @@ impl BridgeResource { } } -/// Generate ObserverSnapshot v2 from ECS state. -/// Pre-visibility version: sends ALL entities (no LOS filtering yet). -/// Will be replaced by perception::observer::compute_observer_snapshot in #112. -pub fn generate_snapshot( - time: Res<crate::simulation::time::SimulationTime>, - entities: Query<( - Entity, - &crate::simulation::movement::TilePosition, - Option<&crate::simulation::movement::PlayerCharacter>, - Option<&crate::npc::Npc>, - )>, - mut buffer: ResMut<SnapshotBuffer>, -) { - let mut visible = Vec::new(); - for (entity, pos, is_player, is_npc) in entities.iter() { - let (x, y, z) = pos.to_render_coords(); - let kind = if is_player.is_some() { - EntityKind::Player - } else if is_npc.is_some() { - EntityKind::Npc - } else { - EntityKind::Object - }; - visible.push(VisibleEntity { - entity_id: entity.to_bits(), - x, - y, - z, - kind, - visibility: VisibilitySector::Forward, - relationship: RelationshipState::Unknown, - observation: EntityVisibility::Visible, - }); - } - - let game_time = GameTime { - day: time.day(), - time_of_day: time.time_of_day_minutes(), - day_phase: time.day_phase(), - tick_rate: time.tick_rate, - }; - - tracing::trace!( - "generate_snapshot: tick={}, entities={}", - time.tick, - visible.len() - ); - buffer.snapshot = Some(ObserverSnapshot { - version: 4, - tick: time.tick, - game_time, - player_facing: FacingDirection::default(), - entities: visible, - visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering - nearby_interactions: Vec::new(), - }); -} - /// Receive inputs from bridge and push to InputQueue pub fn receive_bridge_inputs( bridge: Option<Res<BridgeResource>>, From ba867564a8d92051afa3d0ad334493a3fa3ebe10 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 21:22:34 +0100 Subject: [PATCH 12/21] fix(server): add error logging, protocol version, and architecture docs - Replace silent Entity::to_bits() fallbacks with tracing::error in observer.rs and interaction.rs (makes unregistered entities loud) - Add PROTOCOL_VERSION constant to types.rs, use in observer snapshot - Document single-observer assumption on NearbyInteractionBuffer - Document proximity-only (no LOS) limitation on compute_nearby_interactions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/bridge/types.rs | 9 +++++++++ server/src/perception/observer.rs | 7 +++++-- server/src/simulation/interaction.rs | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index f73637c6d..06e9cb311 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -8,6 +8,15 @@ use serde::{Deserialize, Serialize}; pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; pub use crate::simulation::time::{DayPhase, TickRate}; +/// Wire protocol version for ObserverSnapshot. +/// +/// Versioning strategy: flat struct + serde defaults for field evolution. +/// Client and server are co-versioned (subprocess IPC per D-020), so protocol +/// negotiation is unnecessary. Client should reject snapshots with version != +/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration +/// period, then the default is removed once both sides are updated. +pub const PROTOCOL_VERSION: u8 = 4; + /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. /// diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 9e21cf567..057475059 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -118,7 +118,10 @@ pub fn compute_observer_snapshot( let wire_id = registry .to_stable(entity) .map(|sid| sid.0) - .unwrap_or_else(|| entity.to_bits()); + .unwrap_or_else(|| { + tracing::error!(?entity, "entity visible but not in EntityRegistry"); + entity.to_bits() + }); visible_entity_bits.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, @@ -160,7 +163,7 @@ pub fn compute_observer_snapshot( // Step 8: Assemble snapshot (v4: added nearby_interactions) buffer.snapshot = Some(ObserverSnapshot { - version: 4, + version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, game_time, player_facing: facing, diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index f304eae87..c9b2c12ba 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -20,8 +20,12 @@ pub(crate) const MID_RANGE: u32 = 5; pub struct Interactable; /// Compute nearby interactions for the player character. -/// For each visible entity in range, determines available verbs sorted by priority. +/// For each entity in range, determines available verbs sorted by priority. /// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot. +/// +/// NOTE: Checks proximity only, not line-of-sight. The client filters +/// interaction prompts against visible entities. Server-side LOS filtering +/// is deferred until the interaction system can read the observer's visible set. #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>, @@ -130,7 +134,10 @@ pub fn compute_nearby_interactions( let wire_id = registry .to_stable(entity) .map(|sid| sid.0) - .unwrap_or_else(|| entity.to_bits()); + .unwrap_or_else(|| { + tracing::error!(?entity, "entity in interaction range but not in EntityRegistry"); + entity.to_bits() + }); buffer.interactions.push(NearbyInteraction { entity_id: wire_id, @@ -148,6 +155,9 @@ pub fn compute_nearby_interactions( /// Buffer for nearby interaction results, consumed by snapshot generation. /// Field is private — use `take()` to drain results into the snapshot. +/// +/// Global Resource — single-observer assumption (v0.1). D-009 multiplayer +/// will refactor the entire observer + interaction pipeline to per-entity. #[derive(Resource, Debug, Default)] pub struct NearbyInteractionBuffer { interactions: Vec<NearbyInteraction>, From c970d2eba7d7a3cc60d118ff810e2e7f2299c98d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 21:23:17 +0100 Subject: [PATCH 13/21] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b05e247..a986d77d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4 - Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions - Content schema definitions (#386) — 8 JSON Schema files (draft 2020-12) for district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, fact-catalog validation +- PROTOCOL_VERSION constant in bridge types — versioning strategy documented (subprocess IPC, serde defaults for field evolution) +- Campaign, system, station JSON schemas for hierarchical content validation ### Fixed - Wire entity_id now uses StableId consistently across observer, observation, interpretation, and interaction systems (was Entity::to_bits() in some paths) - Restored system/station/district hierarchical fields in district metadata (incorrectly removed during canonical_id cleanup) +- Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits() ### Changed +- Content directory restructured from flat districts/ to hierarchical campaigns/main/systems/krenn/stations/sova/districts/transit/ — path mirrors canonical IDs, glob-based discovery, multi-campaign/DLC ready +- Content manifest (content.yaml) rewritten for glob-based district discovery +- District identity fields (system, station, district) now derived from directory path — removed from district.yaml required fields +- NPC canonical_id schema accepts district-scoped IDs (npc:transit.kael-davan) for cross-district uniqueness - ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field, tick_rate replaces paused field in GameTime - NearbyInteractionBuffer.interactions is now private with take() accessor (no per-frame clone) - NearbyInteraction.distance changed from f32 to u32 (matches manhattan distance) @@ -27,6 +34,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them - Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure +### Removed +- Dead generate_snapshot function in bridge/mod.rs — superseded by compute_observer_snapshot +- content/global/regions/ directory — region data absorbed into system.yaml metadata + ### Added - Dual Lens Authoring Guide — 7-chapter reference for writing content that works for both smuggler and detective perspectives (D-027, D-028, D-032, D-034, D-035) - THE MIRROR pattern spec — transparency-as-contrast NPC design with Naia Tamm reference implementation and generator template From 6f9537fff37d27256cfb372b920545d4b6f21cc5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 22:52:47 +0100 Subject: [PATCH 14/21] refactor(server): NearbyInteractionBuffer from Resource to Component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-entity Component attached to PlayerCharacter instead of global Resource. Makes the interaction buffer multiplayer-ready (D-009) — each observer gets their own buffer without pipeline refactoring. Updated all 8 files touching the buffer: system signatures, player spawn bundles, and ~30 test spawn sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/main.rs | 2 + server/src/perception/interpretation.rs | 6 +- server/src/perception/observation.rs | 4 +- server/src/perception/observer.rs | 798 ------------------------ server/src/perception/observer/mod.rs | 244 ++++++++ server/src/simulation/interaction.rs | 133 ++-- server/src/simulation/mod.rs | 1 - server/tests/game_loop.rs | 2 + 8 files changed, 317 insertions(+), 873 deletions(-) delete mode 100644 server/src/perception/observer.rs create mode 100644 server/src/perception/observer/mod.rs diff --git a/server/src/main.rs b/server/src/main.rs index 4f4318359..96991b5e6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,7 @@ use settled_reach_server::npc::{ Want, WantKind, }; use settled_reach_server::perception::vision_cone::Facing; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::path_follow::MovementSpeed; use settled_reach_server::simulation::time::DayPhase; @@ -68,6 +69,7 @@ fn main() { TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 252d7a2ca..1c78ae395 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -199,7 +199,6 @@ mod tests { world.init_resource::<crate::knowledge::KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); world.init_resource::<ObservationEventQueue>(); - world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>(); world } @@ -235,6 +234,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -285,6 +285,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -355,6 +356,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -387,6 +389,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), // Empty — never seen anyone + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -433,6 +436,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 05b63f255..ecfe9bd07 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -101,7 +101,6 @@ mod tests { world.init_resource::<SnapshotBuffer>(); world.init_resource::<KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); - world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>(); world } @@ -116,6 +115,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -159,6 +159,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); @@ -195,6 +196,7 @@ mod tests { TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), + crate::simulation::interaction::NearbyInteractionBuffer::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs deleted file mode 100644 index 057475059..000000000 --- a/server/src/perception/observer.rs +++ /dev/null @@ -1,798 +0,0 @@ -//! Observer visibility query system (#112) -//! -//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version. -//! Combines shadowcasting + vision cone to determine what the observer can see, -//! then populates ObserverSnapshot v2 with only visible entities and tiles. - -use bevy_ecs::prelude::*; -use std::collections::HashSet; - -use crate::bridge::types::*; -use crate::knowledge::{EntityRegistry, KnowledgeGraph}; -use crate::perception::shadowcast::compute_fov; -use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; -use crate::simulation::interaction::NearbyInteractionBuffer; -use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; -use crate::simulation::time::SimulationTime; - -/// Compute observer snapshot with LOS filtering and vision cone. -/// -/// System ordering: after validate_movement, before advance_tick. -/// Replaces bridge::generate_snapshot. -pub fn compute_observer_snapshot( - time: Res<SimulationTime>, - walkability: Res<WalkabilityMap>, - registry: Res<EntityRegistry>, - mut interaction_buffer: ResMut<NearbyInteractionBuffer>, - observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>, - all_entities: Query<( - Entity, - &TilePosition, - Option<&PlayerCharacter>, - Option<&crate::npc::Npc>, - )>, - mut buffer: ResMut<SnapshotBuffer>, -) { - let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else { - return; - }; - - let facing = facing_opt - .map(|f| f.0) - .unwrap_or(FacingDirection::default()); - - let config = VisionConeConfig::default(); - let z = observer_pos.z; - - // Step 1: Compute raw FOV using symmetric shadowcasting - let fov = compute_fov( - |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), - observer_pos.x, - observer_pos.y, - config.forward_range, - z, - ); - - // Step 2: Apply vision cone to get sector-tagged tiles - let cone_tiles = - apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); - - // Step 3: Build visible_tiles for the snapshot - let visible_tiles: Vec<VisibleTile> = cone_tiles - .iter() - .map(|&(x, y, sector)| VisibleTile { - x, - y, - z, - visibility: sector, - }) - .collect(); - - // Step 4: Build lookup set for fast entity visibility check - let visible_positions: HashSet<(i32, i32)> = - cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); - - // Build sector lookup (position -> sector) - let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles - .iter() - .map(|&(x, y, sector)| ((x, y), sector)) - .collect(); - - // Step 5: Filter entities by visibility, overlay knowledge - let mut entities = Vec::new(); - let mut visible_entity_bits: HashSet<u64> = HashSet::new(); - for (entity, pos, is_player, is_npc) in all_entities.iter() { - // Different z-level: not visible - if pos.z != z { - continue; - } - - // Not in visible tile set: not visible - if !visible_positions.contains(&(pos.x, pos.y)) { - continue; - } - - let (rx, ry, rz) = pos.to_render_coords(); - let kind = if is_player.is_some() { - EntityKind::Player - } else if is_npc.is_some() { - EntityKind::Npc - } else { - EntityKind::Object - }; - - let sector = sector_lookup - .get(&(pos.x, pos.y)) - .copied() - .unwrap_or(VisibilitySector::Peripheral); - - // Look up relationship from knowledge graph (D-033 entity color) - let relationship = if is_player.is_some() { - RelationshipState::Known // Self - } else if let Some(stable_id) = registry.to_stable(entity) { - observer_kg.relationship_with(&stable_id) - } else { - RelationshipState::Unknown - }; - - let wire_id = registry - .to_stable(entity) - .map(|sid| sid.0) - .unwrap_or_else(|| { - tracing::error!(?entity, "entity visible but not in EntityRegistry"); - entity.to_bits() - }); - visible_entity_bits.insert(wire_id); - entities.push(VisibleEntity { - entity_id: wire_id, - x: rx, - y: ry, - z: rz, - kind, - visibility: sector, - relationship, - observation: EntityVisibility::Visible, - }); - } - - // Step 6: Add remembered entities from knowledge graph (#366) - collect_remembered_entities( - observer_kg, - &visible_entity_bits, - &visible_positions, - z, - time.tick, - &mut entities, - ); - - // Step 7: Build GameTime from SimulationTime - let game_time = GameTime { - day: time.day(), - time_of_day: time.time_of_day_minutes(), - day_phase: time.day_phase(), - tick_rate: time.tick_rate, - }; - - tracing::trace!( - "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", - time.tick, - visible_entity_bits.len(), - entities.len() - visible_entity_bits.len(), - visible_tiles.len(), - ); - - // Step 8: Assemble snapshot (v4: added nearby_interactions) - buffer.snapshot = Some(ObserverSnapshot { - version: crate::bridge::types::PROTOCOL_VERSION, - tick: time.tick, - game_time, - player_facing: facing, - entities, - visible_tiles, - nearby_interactions: interaction_buffer.take(), - }); -} - -/// Collect remembered entities from the knowledge graph — entities the observer -/// knows about but can't currently see. Filters out: already-visible entities, -/// entities without known positions, wrong z-level, visible-tile ghosts, and -/// transient Direct-confidence inconsistencies. -fn collect_remembered_entities( - observer_kg: &KnowledgeGraph, - visible_ids: &HashSet<u64>, - visible_positions: &HashSet<(i32, i32)>, - observer_z: i32, - current_tick: u64, - entities: &mut Vec<VisibleEntity>, -) { - for (stable_id, knowledge) in observer_kg.known_entities_iter() { - if visible_ids.contains(&stable_id.0) { - continue; - } - - let Some(position) = knowledge.last_known_position else { - continue; - }; - - if position.z != observer_z { - continue; - } - - // Tile is visible but entity isn't there — player knows it moved - if visible_positions.contains(&(position.x, position.y)) { - continue; - } - - // Direct confidence = should be in LOS; skip transient inconsistency - if knowledge.confidence == KnowledgeConfidence::Direct { - continue; - } - - let (rx, ry, rz) = position.to_render_coords(); - debug_assert!( - knowledge.last_observed_tick <= current_tick, - "last_observed_tick {} > current tick {}", - knowledge.last_observed_tick, - current_tick, - ); - let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick); - - entities.push(VisibleEntity { - entity_id: stable_id.0, - x: rx, - y: ry, - z: rz, - kind: EntityKind::Npc, - visibility: VisibilitySector::Forward, - relationship: knowledge.relationship, - observation: EntityVisibility::Remembered { - confidence: knowledge.confidence, - age_ticks, - }, - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::knowledge::{EntityRegistry, KnowledgeGraph}; - use crate::perception::vision_cone::Facing; - use bevy_ecs::world::World; - - /// Helper: set up a test world with player, walkability map, and knowledge resources - fn setup_world(width: i32, height: i32) -> World { - let mut world = World::new(); - world.insert_resource(SimulationTime::default()); - world.insert_resource(WalkabilityMap::new(width, height, 1)); - world.init_resource::<SnapshotBuffer>(); - world.init_resource::<EntityRegistry>(); - world.init_resource::<NearbyInteractionBuffer>(); - world - } - - #[test] - fn player_always_visible_in_snapshot() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 4); - assert_eq!(snapshot.entities.len(), 1); - assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); - assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); - } - - #[test] - fn npc_in_los_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // NPC directly north of player (in forward cone) - world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.entities.len(), 2); - let npc = snapshot - .entities - .iter() - .find(|e| matches!(e.kind, EntityKind::Npc)) - .expect("NPC should be visible"); - assert_eq!(npc.visibility, VisibilitySector::Forward); - assert_eq!(npc.observation, EntityVisibility::Visible); - } - - #[test] - fn npc_behind_wall_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // Wall between player and NPC - let mut walkability = world.resource_mut::<WalkabilityMap>(); - walkability.set_walkable(&TilePosition::new(16, 14, 0), false); - // NPC behind the wall - world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Only player should be visible, not the NPC behind the wall - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC behind wall should not be visible"); - } - - #[test] - fn npc_behind_player_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // NPC far behind player (south, in blind spot) - world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC in blind spot should not be visible"); - } - - #[test] - fn different_z_level_not_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - // NPC on different z-level - world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert!(npcs.is_empty(), "NPC on different z should not be visible"); - } - - #[test] - fn game_time_populated() { - let mut world = setup_world(32, 32); - let mut time = SimulationTime::default(); - time.tick = 7200; // 720 minutes = Evening - time.tick_rate = crate::simulation::time::TickRate::Paused; - world.insert_resource(time); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.game_time.time_of_day, 720); - assert_eq!( - snapshot.game_time.day_phase, - crate::simulation::time::DayPhase::Evening - ); - assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); - } - - #[test] - fn visible_tiles_populated() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - )); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - assert!( - !snapshot.visible_tiles.is_empty(), - "should have visible tiles" - ); - // Observer's tile should be in the list - let has_observer_tile = snapshot - .visible_tiles - .iter() - .any(|t| t.x == 16 && t.y == 16 && t.z == 0); - assert!(has_observer_tile, "observer tile should be visible"); - } - - #[test] - fn visible_npc_has_relationship_from_knowledge() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player knows NPC is hostile - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); - kg.set_relationship(&npc_sid, RelationshipState::Hostile); - - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )); - - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - let npc_entity = snapshot - .entities - .iter() - .find(|e| matches!(e.kind, EntityKind::Npc)) - .expect("NPC should be visible"); - assert_eq!(npc_entity.relationship, RelationshipState::Hostile); - assert_eq!(npc_entity.observation, EntityVisibility::Visible); - } - - #[test] - fn remembered_entity_appears_as_ghost() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - // NPC exists far behind the player (not visible) - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player previously saw NPC at (16, 28) — behind the player (south), - // well beyond peripheral range. The tile is NOT in the player's FOV. - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - world.insert_resource(registry); - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - // Should have player (visible) + NPC (remembered) - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert_eq!(remembered.len(), 1, "should have one remembered entity"); - assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); - - // Remembered entity at last_known_position (16, 28), not actual (16, 30) - assert_eq!(remembered[0].x, 16.5); - assert_eq!(remembered[0].y, 28.5); - - if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { - assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); - assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 - } - } - - #[test] - fn direct_confidence_not_shown_as_remembered() { - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - // NPC exists but not in LOS - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Knowledge still shows Direct (transient inconsistency) - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); - // Still Direct — don't show as ghost - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - - // Wall blocks actual NPC position - let mut walkability = world.resource_mut::<WalkabilityMap>(); - walkability.set_walkable(&TilePosition::new(16, 12, 0), false); - - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "Direct-confidence entities should not appear as remembered ghosts" - ); - } - - #[test] - fn remembered_entity_on_visible_tile_not_shown() { - // If the player can see a tile and the entity isn't there, - // don't show a ghost — the player knows it moved. - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player remembers NPC at (16, 15) — a tile the player can currently see - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "ghost should not appear on a tile the player can currently see" - ); - } - - #[test] - fn remembered_entity_different_z_not_shown() { - // Remembered entity on a different z-level should not appear - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) - .id(); - let npc_sid = registry.register(npc); - - // Player remembers NPC at z=1, but player is at z=0 - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); - kg.observe_entity_leaving_los(&npc_sid, 60); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "remembered entity on different z-level should not appear in snapshot" - ); - } - - #[test] - fn knowledge_without_position_not_shown() { - // Entity known via gossip (no last_known_position) should not appear - let mut world = setup_world(32, 32); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) - .id(); - let npc_sid = registry.register(npc); - - // Player knows about NPC but has never seen it (no position) - let mut kg = KnowledgeGraph::new(); - // Insert knowledge manually without a position - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: None, - last_observed_tick: 0, - last_updated_tick: 50, - confidence: KnowledgeConfidence::KnowsOf, - source: crate::knowledge::KnowledgeSource::Background, - state: crate::knowledge::KnowledgeState::Active, - relationship: RelationshipState::PersonOfInterest, - known_attributes: std::collections::BTreeMap::new(), - }); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - kg, - )) - .id(); - registry.register(player); - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - - let remembered: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) - .collect(); - assert!( - remembered.is_empty(), - "entity without last_known_position should not appear as ghost" - ); - } - - #[test] - fn multiple_npcs_in_los_all_visible() { - let mut world = setup_world(32, 32); - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // Three NPCs in front of player, no walls - world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); - world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); - world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Player + 3 NPCs = 4 entities - assert_eq!(snapshot.entities.len(), 4); - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert_eq!(npcs.len(), 3); - assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); - } - - #[test] - fn npc_behind_wall_excluded_from_multi_entity_snapshot() { - let mut world = setup_world(32, 32); - // Wall at (16,14) - world - .resource_mut::<WalkabilityMap>() - .set_walkable(&TilePosition::new(16, 14, 0), false); - - world.spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing(FacingDirection::North), - KnowledgeGraph::new(), - )); - // NPC 1: behind wall (should be hidden) - world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0))); - // NPC 2: to the side, no wall (should be visible) - world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); - // NPC 3: also visible - world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0))); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); - - let buffer = world.resource::<SnapshotBuffer>(); - let snapshot = buffer.snapshot.as_ref().unwrap(); - // Player + 2 visible NPCs = 3 (NPC behind wall excluded) - let npcs: Vec<_> = snapshot - .entities - .iter() - .filter(|e| matches!(e.kind, EntityKind::Npc)) - .collect(); - assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); - } -} diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs new file mode 100644 index 000000000..1d1796b39 --- /dev/null +++ b/server/src/perception/observer/mod.rs @@ -0,0 +1,244 @@ +//! Observer visibility query system (#112) +//! +//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version. +//! Combines shadowcasting + vision cone to determine what the observer can see, +//! then populates ObserverSnapshot v2 with only visible entities and tiles. + +use bevy_ecs::prelude::*; +use std::collections::HashSet; + +use crate::bridge::types::*; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::perception::shadowcast::compute_fov; +use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; +use crate::simulation::interaction::NearbyInteractionBuffer; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::time::SimulationTime; + +/// Compute observer snapshot with LOS filtering and vision cone. +/// +/// System ordering: after validate_movement, before advance_tick. +/// Replaces bridge::generate_snapshot. +pub fn compute_observer_snapshot( + time: Res<SimulationTime>, + walkability: Res<WalkabilityMap>, + registry: Res<EntityRegistry>, + mut observer_query: Query< + (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer), + With<PlayerCharacter>, + >, + all_entities: Query<( + Entity, + &TilePosition, + Option<&PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, + mut buffer: ResMut<SnapshotBuffer>, +) { + let Ok((observer_pos, facing_opt, observer_kg, mut interaction_buffer)) = + observer_query.single_mut() + else { + return; + }; + + let facing = facing_opt + .map(|f| f.0) + .unwrap_or(FacingDirection::default()); + + let config = VisionConeConfig::default(); + let z = observer_pos.z; + + // Step 1: Compute raw FOV using symmetric shadowcasting + let fov = compute_fov( + |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), + observer_pos.x, + observer_pos.y, + config.forward_range, + z, + ); + + // Step 2: Apply vision cone to get sector-tagged tiles + let cone_tiles = + apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); + + // Step 3: Build visible_tiles for the snapshot + let visible_tiles: Vec<VisibleTile> = cone_tiles + .iter() + .map(|&(x, y, sector)| VisibleTile { + x, + y, + z, + visibility: sector, + }) + .collect(); + + // Step 4: Build lookup set for fast entity visibility check + let visible_positions: HashSet<(i32, i32)> = + cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); + + // Build sector lookup (position -> sector) + let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles + .iter() + .map(|&(x, y, sector)| ((x, y), sector)) + .collect(); + + // Step 5: Filter entities by visibility, overlay knowledge + let mut entities = Vec::new(); + let mut visible_entity_bits: HashSet<u64> = HashSet::new(); + for (entity, pos, is_player, is_npc) in all_entities.iter() { + // Different z-level: not visible + if pos.z != z { + continue; + } + + // Not in visible tile set: not visible + if !visible_positions.contains(&(pos.x, pos.y)) { + continue; + } + + let (rx, ry, rz) = pos.to_render_coords(); + let kind = if is_player.is_some() { + EntityKind::Player + } else if is_npc.is_some() { + EntityKind::Npc + } else { + EntityKind::Object + }; + + let sector = sector_lookup + .get(&(pos.x, pos.y)) + .copied() + .unwrap_or(VisibilitySector::Peripheral); + + // Look up relationship from knowledge graph (D-033 entity color) + let relationship = if is_player.is_some() { + RelationshipState::Known // Self + } else if let Some(stable_id) = registry.to_stable(entity) { + observer_kg.relationship_with(&stable_id) + } else { + RelationshipState::Unknown + }; + + // Fallback to Entity::to_bits() is intentional for per-frame systems: + // panicking would crash the server every tick. The error log makes this + // loud enough to catch in testing while keeping the server alive. + let wire_id = registry + .to_stable(entity) + .map(|sid| sid.0) + .unwrap_or_else(|| { + tracing::error!(?entity, "entity visible but not in EntityRegistry"); + entity.to_bits() + }); + visible_entity_bits.insert(wire_id); + entities.push(VisibleEntity { + entity_id: wire_id, + x: rx, + y: ry, + z: rz, + kind, + visibility: sector, + relationship, + observation: EntityVisibility::Visible, + }); + } + + // Step 6: Add remembered entities from knowledge graph (#366) + collect_remembered_entities( + observer_kg, + &visible_entity_bits, + &visible_positions, + z, + time.tick, + &mut entities, + ); + + // Step 7: Build GameTime from SimulationTime + let game_time = GameTime { + day: time.day(), + time_of_day: time.time_of_day_minutes(), + day_phase: time.day_phase(), + tick_rate: time.tick_rate, + }; + + tracing::trace!( + "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", + time.tick, + visible_entity_bits.len(), + entities.len() - visible_entity_bits.len(), + visible_tiles.len(), + ); + + // Step 8: Assemble snapshot (v4: added nearby_interactions) + buffer.snapshot = Some(ObserverSnapshot { + version: crate::bridge::types::PROTOCOL_VERSION, + tick: time.tick, + game_time, + player_facing: facing, + entities, + visible_tiles, + nearby_interactions: interaction_buffer.take(), + }); +} + +/// Collect remembered entities from the knowledge graph — entities the observer +/// knows about but can't currently see. Filters out: already-visible entities, +/// entities without known positions, wrong z-level, visible-tile ghosts, and +/// transient Direct-confidence inconsistencies. +fn collect_remembered_entities( + observer_kg: &KnowledgeGraph, + visible_ids: &HashSet<u64>, + visible_positions: &HashSet<(i32, i32)>, + observer_z: i32, + current_tick: u64, + entities: &mut Vec<VisibleEntity>, +) { + for (stable_id, knowledge) in observer_kg.known_entities_iter() { + if visible_ids.contains(&stable_id.0) { + continue; + } + + let Some(position) = knowledge.last_known_position else { + continue; + }; + + if position.z != observer_z { + continue; + } + + // Tile is visible but entity isn't there — player knows it moved + if visible_positions.contains(&(position.x, position.y)) { + continue; + } + + // Direct confidence = should be in LOS; skip transient inconsistency + if knowledge.confidence == KnowledgeConfidence::Direct { + continue; + } + + let (rx, ry, rz) = position.to_render_coords(); + debug_assert!( + knowledge.last_observed_tick <= current_tick, + "last_observed_tick {} > current tick {}", + knowledge.last_observed_tick, + current_tick, + ); + let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick); + + entities.push(VisibleEntity { + entity_id: stable_id.0, + x: rx, + y: ry, + z: rz, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: knowledge.relationship, + observation: EntityVisibility::Remembered { + confidence: knowledge.confidence, + age_ticks, + }, + }); + } +} + +#[cfg(test)] +mod tests; diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index c9b2c12ba..d931da5fe 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -28,19 +28,20 @@ pub struct Interactable; /// is deferred until the interaction system can read the observer's visible set. #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( - player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>, + mut player_query: Query< + (&TilePosition, &KnowledgeGraph, &mut NearbyInteractionBuffer), + With<PlayerCharacter>, + >, registry: Res<EntityRegistry>, interactables: Query< (Entity, &TilePosition, Option<&Npc>), (With<Interactable>, Without<PlayerCharacter>), >, - mut buffer: ResMut<NearbyInteractionBuffer>, ) { - buffer.interactions.clear(); - - let Ok((player_pos, knowledge)) = player_query.single() else { + let Ok((player_pos, knowledge, mut buffer)) = player_query.single_mut() else { return; }; + buffer.interactions.clear(); for (entity, pos, is_npc) in interactables.iter() { let Some(distance) = player_pos.manhattan_distance(pos) else { @@ -131,6 +132,9 @@ pub fn compute_nearby_interactions( // Sort by priority (lower = higher), then by kind discriminant for stability verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + // Fallback to Entity::to_bits() is intentional for per-frame systems: + // panicking would crash the server every tick. The error log makes this + // loud enough to catch in testing while keeping the server alive. let wire_id = registry .to_stable(entity) .map(|sid| sid.0) @@ -156,9 +160,9 @@ pub fn compute_nearby_interactions( /// Buffer for nearby interaction results, consumed by snapshot generation. /// Field is private — use `take()` to drain results into the snapshot. /// -/// Global Resource — single-observer assumption (v0.1). D-009 multiplayer -/// will refactor the entire observer + interaction pipeline to per-entity. -#[derive(Resource, Debug, Default)] +/// Per-entity Component attached to the PlayerCharacter. Each observer gets +/// their own interaction buffer, so D-009 multiplayer works without refactoring. +#[derive(Component, Debug, Default)] pub struct NearbyInteractionBuffer { interactions: Vec<NearbyInteraction>, } @@ -180,28 +184,40 @@ mod tests { fn setup_world() -> World { let mut world = World::new(); world.init_resource::<EntityRegistry>(); - world.init_resource::<NearbyInteractionBuffer>(); world } + /// Spawn player with standard components + NearbyInteractionBuffer + fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { + world + .spawn(( + PlayerCharacter, + TilePosition::new(x, y, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )) + .id() + } + + /// Read the player's NearbyInteractionBuffer component + fn read_buffer(world: &mut World) -> &NearbyInteractionBuffer { + let mut query = world.query_filtered::<&NearbyInteractionBuffer, With<PlayerCharacter>>(); + query.single(world).unwrap() + } + #[test] fn npc_in_close_range_gets_talk_and_observe() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 2); - // Talk should be priority 1 (default, not POI) assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); } @@ -209,19 +225,14 @@ mod tests { #[test] fn npc_in_mid_range_gets_observe_only() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); - // Distance 4 (mid range, beyond close) + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 1); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); @@ -230,19 +241,14 @@ mod tests { #[test] fn npc_out_of_range_no_interactions() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); - // Distance 8 (beyond mid range) + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert!(buffer.interactions.is_empty()); } @@ -260,16 +266,20 @@ mod tests { kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + kg, + NearbyInteractionBuffer::default(), + )); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); - // Observe should be priority 1 for POI NPC assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk); } @@ -277,19 +287,14 @@ mod tests { #[test] fn object_in_close_range_gets_examine() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); - // Object (no Npc component) at close range + spawn_player(&mut world, 5, 5); world.spawn((TilePosition::new(5, 6, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 1); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject); @@ -298,39 +303,29 @@ mod tests { #[test] fn different_z_level_no_interactions() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert!(buffer.interactions.is_empty()); } #[test] fn multiple_entities_sorted_by_distance() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); - // Farther NPC + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); - // Closer NPC world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 2); assert!(buffer.interactions[0].distance < buffer.interactions[1].distance); } @@ -338,25 +333,19 @@ mod tests { #[test] fn non_interactable_entity_ignored() { let mut world = setup_world(); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); - // NPC without Interactable component + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 6, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert!(buffer.interactions.is_empty()); } #[test] fn poi_npc_at_mid_range_gets_observe_only() { - // POI priority flip only applies at close range — mid range always Observe-only let mut world = setup_world(); let mut registry = EntityRegistry::new(0); @@ -369,28 +358,29 @@ mod tests { kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + kg, + NearbyInteractionBuffer::default(), + )); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 1); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[0].priority, 1); } #[test] fn equidistant_npcs_sorted_deterministically() { let mut world = setup_world(); - // Two NPCs at equal distance (1 tile each) - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - KnowledgeGraph::new(), - )); + spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable)); world.spawn((Npc, TilePosition::new(4, 5, 0), Interactable)); @@ -398,9 +388,8 @@ mod tests { schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); - let buffer = world.resource::<NearbyInteractionBuffer>(); + let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 2); - // Both at distance 1 — order should be stable across runs assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance); } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 086365d82..5805cd3ec 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -23,7 +23,6 @@ impl Plugin for SimulationPlugin { app.init_resource::<time::SimulationTime>() .insert_resource(rng::SimRng::new(0)) .init_resource::<input::InputQueue>() - .init_resource::<interaction::NearbyInteractionBuffer>() .init_resource::<crate::knowledge::EntityRegistry>() .add_systems( Update, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index a8bf8b52f..76e634e36 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -7,6 +7,7 @@ use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::SimulationPlugin; use std::io::{BufReader, BufWriter}; @@ -34,6 +35,7 @@ fn player_moves_north_through_full_pipeline() { PlayerCharacter, TilePosition::new(16, 16, 0), KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), )); // Run one tick: receive input, process, validate movement, generate snapshot, send From 02fa89b1d76127ff709863bccc6d3075d1e13e8b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 22:52:52 +0100 Subject: [PATCH 15/21] refactor(server): extract observer tests to separate file Split observer/mod.rs (820 lines) into production code (244 lines) and tests (480 lines). Reduces module size per Hoshe #3 review item. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/perception/observer/tests.rs | 574 ++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 server/src/perception/observer/tests.rs diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs new file mode 100644 index 000000000..96c90778f --- /dev/null +++ b/server/src/perception/observer/tests.rs @@ -0,0 +1,574 @@ +use super::*; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::perception::vision_cone::Facing; +use bevy_ecs::world::World; + +/// Helper: set up a test world with player, walkability map, and knowledge resources +fn setup_world(width: i32, height: i32) -> World { + let mut world = World::new(); + world.insert_resource(SimulationTime::default()); + world.insert_resource(WalkabilityMap::new(width, height, 1)); + world.init_resource::<SnapshotBuffer>(); + world.init_resource::<EntityRegistry>(); + world +} + +#[test] +fn player_always_visible_in_snapshot() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + assert_eq!(snapshot.version, 4); + assert_eq!(snapshot.entities.len(), 1); + assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); + assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); +} + +#[test] +fn npc_in_los_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC directly north of player (in forward cone) + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.entities.len(), 2); + let npc = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + assert_eq!(npc.visibility, VisibilitySector::Forward); + assert_eq!(npc.observation, EntityVisibility::Visible); +} + +#[test] +fn npc_behind_wall_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // Wall between player and NPC + let mut walkability = world.resource_mut::<WalkabilityMap>(); + walkability.set_walkable(&TilePosition::new(16, 14, 0), false); + // NPC behind the wall + world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // Only player should be visible, not the NPC behind the wall + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC behind wall should not be visible"); +} + +#[test] +fn npc_behind_player_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC far behind player (south, in blind spot) + world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC in blind spot should not be visible"); +} + +#[test] +fn different_z_level_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC on different z-level + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC on different z should not be visible"); +} + +#[test] +fn game_time_populated() { + let mut world = setup_world(32, 32); + let mut time = SimulationTime::default(); + time.tick = 7200; // 720 minutes = Evening + time.tick_rate = crate::simulation::time::TickRate::Paused; + world.insert_resource(time); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.game_time.time_of_day, 720); + assert_eq!( + snapshot.game_time.day_phase, + crate::simulation::time::DayPhase::Evening + ); + assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); +} + +#[test] +fn visible_tiles_populated() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + !snapshot.visible_tiles.is_empty(), + "should have visible tiles" + ); + // Observer's tile should be in the list + let has_observer_tile = snapshot + .visible_tiles + .iter() + .any(|t| t.x == 16 && t.y == 16 && t.z == 0); + assert!(has_observer_tile, "observer tile should be visible"); +} + +#[test] +fn visible_npc_has_relationship_from_knowledge() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player knows NPC is hostile + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + assert_eq!(npc_entity.relationship, RelationshipState::Hostile); + assert_eq!(npc_entity.observation, EntityVisibility::Visible); +} + +#[test] +fn remembered_entity_appears_as_ghost() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC exists far behind the player (not visible) + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player previously saw NPC at (16, 28) — behind the player (south), + // well beyond peripheral range. The tile is NOT in the player's FOV. + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // Should have player (visible) + NPC (remembered) + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert_eq!(remembered.len(), 1, "should have one remembered entity"); + assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); + + // Remembered entity at last_known_position (16, 28), not actual (16, 30) + assert_eq!(remembered[0].x, 16.5); + assert_eq!(remembered[0].y, 28.5); + + if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { + assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); + assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 + } +} + +#[test] +fn direct_confidence_not_shown_as_remembered() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC exists but not in LOS + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Knowledge still shows Direct (transient inconsistency) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + // Still Direct — don't show as ghost + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + + // Wall blocks actual NPC position + let mut walkability = world.resource_mut::<WalkabilityMap>(); + walkability.set_walkable(&TilePosition::new(16, 12, 0), false); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "Direct-confidence entities should not appear as remembered ghosts" + ); +} + +#[test] +fn remembered_entity_on_visible_tile_not_shown() { + // If the player can see a tile and the entity isn't there, + // don't show a ghost — the player knows it moved. + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at (16, 15) — a tile the player can currently see + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "ghost should not appear on a tile the player can currently see" + ); +} + +#[test] +fn remembered_entity_different_z_not_shown() { + // Remembered entity on a different z-level should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at z=1, but player is at z=0 + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "remembered entity on different z-level should not appear in snapshot" + ); +} + +#[test] +fn knowledge_without_position_not_shown() { + // Entity known via gossip (no last_known_position) should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player knows about NPC but has never seen it (no position) + let mut kg = KnowledgeGraph::new(); + // Insert knowledge manually without a position + kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: crate::knowledge::KnowledgeSource::Background, + state: crate::knowledge::KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: std::collections::BTreeMap::new(), + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "entity without last_known_position should not appear as ghost" + ); +} + +#[test] +fn multiple_npcs_in_los_all_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // Three NPCs in front of player, no walls + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); + world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); + world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // Player + 3 NPCs = 4 entities + assert_eq!(snapshot.entities.len(), 4); + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 3); + assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); +} + +#[test] +fn npc_behind_wall_excluded_from_multi_entity_snapshot() { + let mut world = setup_world(32, 32); + // Wall at (16,14) + world + .resource_mut::<WalkabilityMap>() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC 1: behind wall (should be hidden) + world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0))); + // NPC 2: to the side, no wall (should be visible) + world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); + // NPC 3: also visible + world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // Player + 2 visible NPCs = 3 (NPC behind wall excluded) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); +} From 0157d1fa385c172a787c7b969a1f129369003e78 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 22:52:59 +0100 Subject: [PATCH 16/21] test(server): add Entity::to_bits roundtrip and TickRate switch tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity::to_bits() roundtrip test guards against bevy version changes silently breaking wire IDs (Hoshe #1) - PROTOCOL_VERSION constant used in test helpers instead of hardcoded 4 - TickRate switch mid-accumulation test verifies Half→Full→Paused→Half transitions preserve accumulator state correctly (Tyre N3) - POI mid-range test now asserts priority=1 (Hoshe #4) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/simulation/time.rs | 31 +++++++++++++++++++++++++++++++ server/tests/serialization.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index 76d9dc554..b743cda98 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -199,6 +199,37 @@ mod tests { assert_eq!(time.day(), 3); } + #[test] + fn tick_rate_switch_mid_accumulation() { + // Half->Full with 0.5 remainder: Full should tick immediately (0.5 + 1.0 >= 1.0) + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(advance_tick); + + // Frame 1: Half rate, accumulate 0.5, no tick + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 0); + + // Switch to Full mid-accumulation (0.5 remainder) + world.resource_mut::<SimulationTime>().tick_rate = TickRate::Full; + + // Frame 2: Full rate adds 1.0 to 0.5 remainder → tick fires + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 1); + + // Switch to Paused: no advance regardless of accumulator + world.resource_mut::<SimulationTime>().tick_rate = TickRate::Paused; + schedule.run(&mut world); + assert_eq!(world.resource::<SimulationTime>().tick, 1); + + // Switch back to Half: accumulator still has 0.5 from overshoot + world.resource_mut::<SimulationTime>().tick_rate = TickRate::Half; + schedule.run(&mut world); + // 0.5 (leftover) + 0.5 (Half) = 1.0 → tick fires + assert_eq!(world.resource::<SimulationTime>().tick, 2); + } + #[test] fn half_rate_no_drift_over_10000_frames() { let mut world = bevy_ecs::world::World::new(); diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 80fbfb819..4b5f2ca91 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -7,7 +7,7 @@ use std::fs; /// Helper to create a minimal v2 snapshot for tests fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot { ObserverSnapshot { - version: 4, + version: PROTOCOL_VERSION, tick, game_time: GameTime { day: 0, @@ -231,6 +231,35 @@ fn snapshot_v2_fields_roundtrip() { assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward); } +/// Entity::to_bits() must roundtrip through from_bits() — guards against +/// bevy version changes silently breaking wire IDs (Hoshe #12). +#[test] +fn entity_to_bits_roundtrip() { + use bevy_ecs::entity::Entity; + // Create entities via a World so we get valid index+generation pairs + let mut world = bevy_ecs::world::World::new(); + let e1 = world.spawn_empty().id(); + let e2 = world.spawn_empty().id(); + let e3 = world.spawn_empty().id(); + // Despawn and respawn to get a higher generation + world.despawn(e2); + let e4 = world.spawn_empty().id(); + + for entity in [e1, e2, e3, e4] { + let bits = entity.to_bits(); + let restored = Entity::from_bits(bits); + assert_eq!(entity, restored, "Entity::to_bits() roundtrip failed for {:?}", entity); + } +} + +/// PROTOCOL_VERSION constant matches snapshot version field +#[test] +fn protocol_version_constant_matches_snapshot() { + let snapshot = test_snapshot(0, vec![]); + assert_eq!(snapshot.version, PROTOCOL_VERSION); + assert_eq!(PROTOCOL_VERSION, 4, "bump this assertion when protocol version changes"); +} + /// All FacingDirection variants round-trip #[test] fn all_facing_direction_variants_roundtrip() { From 0c91f5af21435210f8dabe2c1d3492eb4d1a7faf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 22:53:05 +0100 Subject: [PATCH 17/21] chore(tooling): add make validate-content for schema validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python script validates campaign YAML files against JSON schemas in content/_schema/. Maps files to schemas by directory context (npcs/ → npc-profile.schema.json, etc.). Skips comment-only placeholder stubs. Addresses Hoshe #2 review item. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- Makefile | 8 ++- tooling/validate-content | 132 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100755 tooling/validate-content diff --git a/Makefile b/Makefile index e9950d5b6..9f8ac26a8 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server test lint ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ - db-backup db-install + db-backup db-install validate-content # --- Configuration --- @@ -30,6 +30,7 @@ help: @echo " make decisions-coverage Decision-to-ticket coverage by domain" @echo " make decisions-active List active decisions" @echo " make decisions-orphan Decisions without implementing tickets" + @echo " make validate-content Validate content YAML against schemas" @echo "" @echo " GODOT_VERSION=4.6 make setup Override Godot version" @@ -127,6 +128,11 @@ decisions-active: decisions-orphan: @db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)" +# --- Content Validation --- + +validate-content: + @tooling/validate-content + # --- Clean --- clean: diff --git a/tooling/validate-content b/tooling/validate-content new file mode 100755 index 000000000..8686778a7 --- /dev/null +++ b/tooling/validate-content @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Validate content YAML files against their JSON schemas. + +Schema mapping is by directory context: + campaign.yaml → campaign.schema.json + system.yaml → system.schema.json + station.yaml → station.schema.json + district.yaml → district.schema.json + npcs/*.yaml → npc-profile.schema.json + locations/*.yaml → location.schema.json + triangles/*.yaml → triangle.schema.json + dialogue/**/*.yaml → dialogue-pool.schema.json + monologue/**/*.yaml → monologue-pool.schema.json + routines/*.yaml → routine.schema.json + +Files under global/ and content.yaml are skipped (no schema yet). + +Exit code 0 = all valid, 1 = validation errors found. +""" + +import json +import sys +from pathlib import Path + +import jsonschema +import yaml + +CONTENT_DIR = Path(__file__).resolve().parent.parent / "content" +SCHEMA_DIR = CONTENT_DIR / "_schema" + +# Map directory parent name (or filename) to schema file +FILENAME_SCHEMAS = { + "campaign.yaml": "campaign.schema.json", + "system.yaml": "system.schema.json", + "station.yaml": "station.schema.json", + "district.yaml": "district.schema.json", +} + +DIR_SCHEMAS = { + "npcs": "npc-profile.schema.json", + "locations": "location.schema.json", + "triangles": "triangle.schema.json", + "dialogue": "dialogue-pool.schema.json", + "monologue": "monologue-pool.schema.json", + "routines": "routine.schema.json", +} + + +def resolve_schema(yaml_path: Path) -> Path | None: + """Determine which schema applies to a content YAML file.""" + name = yaml_path.name + if name in FILENAME_SCHEMAS: + return SCHEMA_DIR / FILENAME_SCHEMAS[name] + + # Walk up parents to find a matching directory name + rel = yaml_path.relative_to(CONTENT_DIR) + for part in reversed(rel.parts[:-1]): + if part in DIR_SCHEMAS: + return SCHEMA_DIR / DIR_SCHEMAS[part] + + return None + + +def main() -> int: + errors = 0 + validated = 0 + skipped = 0 + + # Cache loaded schemas + schema_cache: dict[str, dict] = {} + + campaigns_dir = CONTENT_DIR / "campaigns" + if not campaigns_dir.exists(): + print(f"No campaigns directory at {campaigns_dir}", file=sys.stderr) + return 1 + + yaml_files = sorted(campaigns_dir.rglob("*.yaml")) + if not yaml_files: + print("No YAML files found under campaigns/", file=sys.stderr) + return 1 + + for yaml_path in yaml_files: + schema_path = resolve_schema(yaml_path) + if schema_path is None: + skipped += 1 + continue + + if not schema_path.exists(): + print(f"MISSING SCHEMA: {schema_path.name} for {yaml_path.relative_to(CONTENT_DIR)}") + errors += 1 + continue + + # Load schema (cached) + schema_key = str(schema_path) + if schema_key not in schema_cache: + with open(schema_path) as f: + schema_cache[schema_key] = json.load(f) + schema = schema_cache[schema_key] + + # Load YAML + try: + with open(yaml_path) as f: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + print(f"YAML ERROR: {yaml_path.relative_to(CONTENT_DIR)}: {e}") + errors += 1 + continue + + if data is None: + # Comment-only or empty placeholder files are valid stubs + skipped += 1 + continue + + # Validate + try: + jsonschema.validate(instance=data, schema=schema) + validated += 1 + except jsonschema.ValidationError as e: + rel = yaml_path.relative_to(CONTENT_DIR) + print(f"INVALID: {rel}") + print(f" Schema: {schema_path.name}") + print(f" Error: {e.message}") + if e.absolute_path: + print(f" Path: {'.'.join(str(p) for p in e.absolute_path)}") + errors += 1 + + print(f"\nValidated {validated} files, {skipped} skipped, {errors} errors") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) From e6bd577a5a28d4d91bee1f9f7a8de410c33cf24e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 22:53:23 +0100 Subject: [PATCH 18/21] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a986d77d6..9a598e4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Content validation tooling — `make validate-content` validates campaign YAML files against JSON schemas, maps files by directory context +- Entity::to_bits() roundtrip test — guards against bevy version changes silently breaking wire IDs +- TickRate switch mid-accumulation test — verifies Half→Full→Paused→Half transitions preserve accumulator state - Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag - Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4 - Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions @@ -20,6 +23,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits() ### Changed +- NearbyInteractionBuffer refactored from global Resource to per-entity Component on PlayerCharacter — multiplayer-ready (D-009) +- Observer module split into mod.rs (244 lines) + tests.rs (480 lines) — reduces module complexity - Content directory restructured from flat districts/ to hierarchical campaigns/main/systems/krenn/stations/sova/districts/transit/ — path mirrors canonical IDs, glob-based discovery, multi-campaign/DLC ready - Content manifest (content.yaml) rewritten for glob-based district discovery - District identity fields (system, station, district) now derived from directory path — removed from district.yaml required fields From 18d8253bfe467cd7be29c5a02f11a036dd36e9ae Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 23:32:41 +0100 Subject: [PATCH 19/21] refactor(server): decompose observer pipeline and fix interaction boundary Extract visibility geometry into a separate system behind a PerceptionQuery trait, enabling D-017 perception mode swapping. Two-stage pipeline: compute_visibility_geometry writes to VisibilityGeometry resource, compute_observer_snapshot reads it. Remove KnowledgeGraph from compute_nearby_interactions (simulation phase boundary violation). Verb availability stays in simulation; POI-based priority adjustment moves to observer via apply_poi_verb_priority helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- server/src/bridge/mod.rs | 15 +- server/src/perception/interpretation.rs | 13 +- server/src/perception/mod.rs | 3 + server/src/perception/observation.rs | 23 ++- server/src/perception/observer/mod.rs | 203 ++++++++++++++---------- server/src/perception/observer/tests.rs | 135 ++++++++++------ server/src/perception/query.rs | 103 ++++++++++++ server/src/simulation/interaction.rs | 142 ++++------------- server/src/simulation/mod.rs | 5 +- 9 files changed, 380 insertions(+), 262 deletions(-) create mode 100644 server/src/perception/query.rs diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 56e20a558..e47532916 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -145,18 +145,25 @@ impl Plugin for BridgePlugin { fn build(&self, app: &mut App) { app.init_resource::<SnapshotBuffer>() .init_resource::<ServerRunning>() + .init_resource::<crate::perception::query::VisibilityGeometry>() + .init_resource::<crate::perception::query::ActivePerceptionMode>() .add_systems( Update, ( - receive_bridge_inputs.before(crate::simulation::input::process_player_input), + receive_bridge_inputs + .before(crate::simulation::input::process_player_input), + crate::perception::observer::compute_visibility_geometry + .after(crate::simulation::movement::validate_movement), + crate::simulation::interaction::compute_nearby_interactions + .after(crate::simulation::movement::validate_movement), crate::perception::observer::compute_observer_snapshot - .after(crate::simulation::movement::validate_movement) + .after(crate::perception::observer::compute_visibility_geometry) + .after(crate::simulation::interaction::compute_nearby_interactions) .before(crate::simulation::time::advance_tick), crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), send_bridge_snapshot - .after(crate::perception::observer::compute_observer_snapshot) - .after(crate::simulation::interaction::compute_nearby_interactions), + .after(crate::perception::observer::compute_observer_snapshot), ), ); tracing::debug!("BridgePlugin initialized"); diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 1c78ae395..338db3c9b 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -186,7 +186,8 @@ mod tests { use super::*; use crate::knowledge::registry::EntityRegistry; use crate::npc::RoutineEntry; - use crate::perception::observer::compute_observer_snapshot; + use crate::perception::observer::{compute_observer_snapshot, compute_visibility_geometry}; + use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::movement::WalkabilityMap; use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE}; @@ -199,16 +200,18 @@ mod tests { world.init_resource::<crate::knowledge::KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); world.init_resource::<ObservationEventQueue>(); + world.init_resource::<VisibilityGeometry>(); + world.init_resource::<ActivePerceptionMode>(); world } - /// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update. - /// Interpretation runs BEFORE knowledge updates so it can detect new entities - /// and compare against the PREVIOUS tick's knowledge state. + /// Run the observation pipeline: geometry -> snapshot -> emit -> interpret -> knowledge. fn run_pipeline(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( - compute_observer_snapshot, + compute_visibility_geometry, + compute_observer_snapshot + .after(compute_visibility_geometry), crate::perception::observation::emit_observation_events .after(compute_observer_snapshot), generate_observation_events diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 38ae3839d..472b86ae1 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -8,6 +8,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod interpretation; pub mod observation; pub mod observer; +pub mod query; pub mod shadowcast; pub mod vision_cone; @@ -18,6 +19,8 @@ pub struct PerceptionPlugin; impl Plugin for PerceptionPlugin { fn build(&self, app: &mut App) { app.init_resource::<interpretation::ObservationEventQueue>() + .init_resource::<query::VisibilityGeometry>() + .init_resource::<query::ActivePerceptionMode>() .add_systems( Update, interpretation::generate_observation_events diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index ecfe9bd07..dff298646 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -90,7 +90,8 @@ mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::Npc; - use crate::perception::observer::compute_observer_snapshot; + use crate::perception::observer::{compute_observer_snapshot, compute_visibility_geometry}; + use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::movement::WalkabilityMap; @@ -101,6 +102,8 @@ mod tests { world.init_resource::<SnapshotBuffer>(); world.init_resource::<KnowledgeEventQueue>(); world.init_resource::<EntityRegistry>(); + world.init_resource::<VisibilityGeometry>(); + world.init_resource::<ActivePerceptionMode>(); world } @@ -129,7 +132,11 @@ mod tests { // First: compute snapshot so NPC is visible let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::<KnowledgeEventQueue>(); @@ -171,7 +178,11 @@ mod tests { walkability.set_walkable(&TilePosition::new(16, 15, 0), false); let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::<KnowledgeEventQueue>(); @@ -203,7 +214,11 @@ mod tests { world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + emit_observation_events.after(compute_observer_snapshot), + )); schedule.run(&mut world); let queue = world.resource::<KnowledgeEventQueue>(); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 1d1796b39..ad5eb1716 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -1,27 +1,52 @@ //! Observer visibility query system (#112) //! -//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version. -//! Combines shadowcasting + vision cone to determine what the observer can see, -//! then populates ObserverSnapshot v2 with only visible entities and tiles. +//! Two-stage pipeline: +//! 1. compute_visibility_geometry — FOV + vision cone → VisibilityGeometry resource +//! 2. compute_observer_snapshot — entity filtering + knowledge overlay → ObserverSnapshot +//! +//! D-017 perception modes swap the geometry producer via PerceptionQuery trait. use bevy_ecs::prelude::*; use std::collections::HashSet; use crate::bridge::types::*; -use crate::knowledge::{EntityRegistry, KnowledgeGraph}; -use crate::perception::shadowcast::compute_fov; -use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; +use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; +use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; +use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::time::SimulationTime; -/// Compute observer snapshot with LOS filtering and vision cone. +/// Compute visibility geometry using the active perception mode. +/// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry. /// -/// System ordering: after validate_movement, before advance_tick. -/// Replaces bridge::generate_snapshot. +/// System ordering: after validate_movement, before compute_observer_snapshot. +pub fn compute_visibility_geometry( + walkability: Res<WalkabilityMap>, + mode: Res<ActivePerceptionMode>, + observer_query: Query<(&TilePosition, Option<&Facing>), With<PlayerCharacter>>, + mut geometry: ResMut<VisibilityGeometry>, +) { + let Ok((observer_pos, facing_opt)) = observer_query.single() else { + return; + }; + + let facing = facing_opt + .map(|f| f.0) + .unwrap_or(FacingDirection::default()); + + *geometry = mode.0.compute_geometry(observer_pos, facing, &walkability); +} + +/// Assemble observer snapshot from precomputed geometry and entity state. +/// Stage 2 of the observer pipeline: entity filtering + knowledge overlay → snapshot. +/// +/// System ordering: after compute_visibility_geometry + compute_nearby_interactions, +/// before advance_tick. +#[allow(clippy::type_complexity)] pub fn compute_observer_snapshot( time: Res<SimulationTime>, - walkability: Res<WalkabilityMap>, + geometry: Res<VisibilityGeometry>, registry: Res<EntityRegistry>, mut observer_query: Query< (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer), @@ -35,7 +60,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut<SnapshotBuffer>, ) { - let Ok((observer_pos, facing_opt, observer_kg, mut interaction_buffer)) = + let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer)) = observer_query.single_mut() else { return; @@ -45,54 +70,70 @@ pub fn compute_observer_snapshot( .map(|f| f.0) .unwrap_or(FacingDirection::default()); - let config = VisionConeConfig::default(); - let z = observer_pos.z; + let (mut entities, visible_ids) = + filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); - // Step 1: Compute raw FOV using symmetric shadowcasting - let fov = compute_fov( - |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), - observer_pos.x, - observer_pos.y, - config.forward_range, - z, + collect_remembered_entities( + observer_kg, + &visible_ids, + &geometry.visible_positions, + geometry.observer_z, + time.tick, + &mut entities, ); - // Step 2: Apply vision cone to get sector-tagged tiles - let cone_tiles = - apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); + let game_time = GameTime { + day: time.day(), + time_of_day: time.time_of_day_minutes(), + day_phase: time.day_phase(), + tick_rate: time.tick_rate, + }; - // Step 3: Build visible_tiles for the snapshot - let visible_tiles: Vec<VisibleTile> = cone_tiles - .iter() - .map(|&(x, y, sector)| VisibleTile { - x, - y, - z, - visibility: sector, - }) - .collect(); + // Take interactions and adjust POI verb priority (D-060) + let mut nearby_interactions = interaction_buffer.take(); + apply_poi_verb_priority(&mut nearby_interactions, observer_kg); - // Step 4: Build lookup set for fast entity visibility check - let visible_positions: HashSet<(i32, i32)> = - cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); + tracing::trace!( + "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", + time.tick, + visible_ids.len(), + entities.len() - visible_ids.len(), + geometry.visible_tiles.len(), + ); - // Build sector lookup (position -> sector) - let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles - .iter() - .map(|&(x, y, sector)| ((x, y), sector)) - .collect(); + buffer.snapshot = Some(ObserverSnapshot { + version: crate::bridge::types::PROTOCOL_VERSION, + tick: time.tick, + game_time, + player_facing: facing, + entities, + visible_tiles: geometry.visible_tiles.clone(), + nearby_interactions, + }); +} - // Step 5: Filter entities by visibility, overlay knowledge +/// Filter entities by visibility using precomputed geometry. +/// Returns (visible entities, set of visible wire IDs). +#[allow(clippy::type_complexity)] +fn filter_visible_entities( + geometry: &VisibilityGeometry, + registry: &EntityRegistry, + observer_kg: &KnowledgeGraph, + all_entities: &Query<( + Entity, + &TilePosition, + Option<&PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, +) -> (Vec<VisibleEntity>, HashSet<u64>) { let mut entities = Vec::new(); - let mut visible_entity_bits: HashSet<u64> = HashSet::new(); + let mut visible_ids: HashSet<u64> = HashSet::new(); + for (entity, pos, is_player, is_npc) in all_entities.iter() { - // Different z-level: not visible - if pos.z != z { + if pos.z != geometry.observer_z { continue; } - - // Not in visible tile set: not visible - if !visible_positions.contains(&(pos.x, pos.y)) { + if !geometry.visible_positions.contains(&(pos.x, pos.y)) { continue; } @@ -105,12 +146,12 @@ pub fn compute_observer_snapshot( EntityKind::Object }; - let sector = sector_lookup + let sector = geometry + .sector_lookup .get(&(pos.x, pos.y)) .copied() .unwrap_or(VisibilitySector::Peripheral); - // Look up relationship from knowledge graph (D-033 entity color) let relationship = if is_player.is_some() { RelationshipState::Known // Self } else if let Some(stable_id) = registry.to_stable(entity) { @@ -129,7 +170,7 @@ pub fn compute_observer_snapshot( tracing::error!(?entity, "entity visible but not in EntityRegistry"); entity.to_bits() }); - visible_entity_bits.insert(wire_id); + visible_ids.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, x: rx, @@ -142,42 +183,7 @@ pub fn compute_observer_snapshot( }); } - // Step 6: Add remembered entities from knowledge graph (#366) - collect_remembered_entities( - observer_kg, - &visible_entity_bits, - &visible_positions, - z, - time.tick, - &mut entities, - ); - - // Step 7: Build GameTime from SimulationTime - let game_time = GameTime { - day: time.day(), - time_of_day: time.time_of_day_minutes(), - day_phase: time.day_phase(), - tick_rate: time.tick_rate, - }; - - tracing::trace!( - "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", - time.tick, - visible_entity_bits.len(), - entities.len() - visible_entity_bits.len(), - visible_tiles.len(), - ); - - // Step 8: Assemble snapshot (v4: added nearby_interactions) - buffer.snapshot = Some(ObserverSnapshot { - version: crate::bridge::types::PROTOCOL_VERSION, - tick: time.tick, - game_time, - player_facing: facing, - entities, - visible_tiles, - nearby_interactions: interaction_buffer.take(), - }); + (entities, visible_ids) } /// Collect remembered entities from the knowledge graph — entities the observer @@ -240,5 +246,28 @@ fn collect_remembered_entities( } } +/// Adjust verb priority for PersonOfInterest NPCs (D-060). +/// Moves ExamineNpc to priority 1 and Talk to priority 2 when the observer +/// knows the entity as POI. Called after interaction buffer is taken. +fn apply_poi_verb_priority( + interactions: &mut [NearbyInteraction], + observer_kg: &KnowledgeGraph, +) { + for interaction in interactions.iter_mut() { + let stable_id = StableId(interaction.entity_id); + let relationship = observer_kg.relationship_with(&stable_id); + if relationship == RelationshipState::PersonOfInterest { + for verb in &mut interaction.verbs { + match verb.kind { + VerbKind::ExamineNpc => verb.priority = 1, + VerbKind::Talk => verb.priority = 2, + _ => {} + } + } + interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + } + } +} + #[cfg(test)] mod tests; diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 96c90778f..78cb50bf0 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -1,18 +1,44 @@ use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use bevy_ecs::world::World; -/// Helper: set up a test world with player, walkability map, and knowledge resources +/// Helper: set up a test world with resources for the two-stage observer pipeline. fn setup_world(width: i32, height: i32) -> World { let mut world = World::new(); world.insert_resource(SimulationTime::default()); world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::<SnapshotBuffer>(); world.init_resource::<EntityRegistry>(); + world.init_resource::<VisibilityGeometry>(); + world.init_resource::<ActivePerceptionMode>(); world } +/// Run the two-stage observer pipeline: geometry + snapshot. +fn run_observer_pipeline(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(( + compute_visibility_geometry, + compute_observer_snapshot.after(compute_visibility_geometry), + )); + schedule.run(world); +} + +/// Run the full pipeline including interaction system. +fn run_full_pipeline(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(( + crate::simulation::interaction::compute_nearby_interactions, + compute_visibility_geometry, + compute_observer_snapshot + .after(compute_visibility_geometry) + .after(crate::simulation::interaction::compute_nearby_interactions), + )); + schedule.run(world); +} + #[test] fn player_always_visible_in_snapshot() { let mut world = setup_world(32, 32); @@ -24,9 +50,7 @@ fn player_always_visible_in_snapshot() { NearbyInteractionBuffer::default(), )); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); @@ -49,9 +73,7 @@ fn npc_in_los_visible() { // NPC directly north of player (in forward cone) world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -81,9 +103,7 @@ fn npc_behind_wall_not_visible() { // NPC behind the wall world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -109,9 +129,7 @@ fn npc_behind_player_not_visible() { // NPC far behind player (south, in blind spot) world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -136,9 +154,7 @@ fn different_z_level_not_visible() { // NPC on different z-level world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -165,9 +181,7 @@ fn game_time_populated() { NearbyInteractionBuffer::default(), )); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -190,9 +204,7 @@ fn visible_tiles_populated() { NearbyInteractionBuffer::default(), )); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -233,9 +245,7 @@ fn visible_npc_has_relationship_from_knowledge() { world.insert_resource(registry); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -279,9 +289,7 @@ fn remembered_entity_appears_as_ghost() { world.insert_resource(registry); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -338,9 +346,7 @@ fn direct_confidence_not_shown_as_remembered() { world.insert_resource(registry); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -385,9 +391,7 @@ fn remembered_entity_on_visible_tile_not_shown() { registry.register(player); world.insert_resource(registry); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -431,9 +435,7 @@ fn remembered_entity_different_z_not_shown() { registry.register(player); world.insert_resource(registry); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -486,9 +488,7 @@ fn knowledge_without_position_not_shown() { registry.register(player); world.insert_resource(registry); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -519,9 +519,7 @@ fn multiple_npcs_in_los_all_visible() { world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -558,9 +556,7 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() { // NPC 3: also visible world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0))); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_observer_snapshot); - schedule.run(&mut world); + run_observer_pipeline(&mut world); let buffer = world.resource::<SnapshotBuffer>(); let snapshot = buffer.snapshot.as_ref().unwrap(); @@ -572,3 +568,50 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() { .collect(); assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); } + +#[test] +fn poi_interaction_gets_observe_first_priority() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC in close range, directly north of player and in LOS + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows NPC as PersonOfInterest + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + // Run full pipeline: interaction computes default priority, + // then observer applies POI adjustment + run_full_pipeline(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // POI: Observe takes priority over Talk + assert_eq!(interaction.verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(interaction.verbs[0].priority, 1); + assert_eq!(interaction.verbs[1].kind, VerbKind::Talk); + assert_eq!(interaction.verbs[1].priority, 2); +} diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs new file mode 100644 index 000000000..a632191b2 --- /dev/null +++ b/server/src/perception/query.rs @@ -0,0 +1,103 @@ +//! Perception query trait (D-017). +//! +//! Abstraction for perception mode geometry computation. Each mode +//! (natural vision, thermal, EM, etc.) implements PerceptionQuery to +//! provide mode-specific FOV and visibility sector computation. +//! v0.1 implements only NaturalVision. + +use std::collections::{HashMap, HashSet}; + +use bevy_ecs::prelude::*; + +use crate::bridge::types::{FacingDirection, VisibilitySector, VisibleTile}; +use crate::perception::shadowcast::compute_fov; +use crate::perception::vision_cone::{apply_vision_cone, VisionConeConfig}; +use crate::simulation::movement::{TilePosition, WalkabilityMap}; + +/// Cached FOV geometry for the current frame. Produced by +/// compute_visibility_geometry, consumed by compute_observer_snapshot. +/// D-017 perception modes swap the geometry producer while the consumer +/// remains unchanged. +#[derive(Resource, Default)] +pub struct VisibilityGeometry { + pub visible_tiles: Vec<VisibleTile>, + pub visible_positions: HashSet<(i32, i32)>, + pub sector_lookup: HashMap<(i32, i32), VisibilitySector>, + pub observer_z: i32, +} + +/// Trait for perception mode geometry computation (D-017). +/// +/// Each perception mode implements this to produce a VisibilityGeometry +/// from the observer's position and facing. v0.1 only implements +/// NaturalVision; D-017 adds Thermal, EM, etc. +pub trait PerceptionQuery: Send + Sync { + fn compute_geometry( + &self, + observer_pos: &TilePosition, + facing: FacingDirection, + walkability: &WalkabilityMap, + ) -> VisibilityGeometry; +} + +/// Natural vision — default perception mode. +/// Uses symmetric shadowcasting (D-011) + directional vision cone (D-015). +pub struct NaturalVision; + +impl PerceptionQuery for NaturalVision { + fn compute_geometry( + &self, + observer_pos: &TilePosition, + facing: FacingDirection, + walkability: &WalkabilityMap, + ) -> VisibilityGeometry { + let config = VisionConeConfig::default(); + let z = observer_pos.z; + + let fov = compute_fov( + |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), + observer_pos.x, + observer_pos.y, + config.forward_range, + z, + ); + + let cone_tiles = + apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); + + let visible_tiles = cone_tiles + .iter() + .map(|&(x, y, sector)| VisibleTile { + x, + y, + z, + visibility: sector, + }) + .collect(); + + let visible_positions = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); + + let sector_lookup = cone_tiles + .iter() + .map(|&(x, y, sector)| ((x, y), sector)) + .collect(); + + VisibilityGeometry { + visible_tiles, + visible_positions, + sector_lookup, + observer_z: z, + } + } +} + +/// Resource wrapping the active perception mode (D-017). +/// Defaults to NaturalVision. Swap this resource to change perception modes. +#[derive(Resource)] +pub struct ActivePerceptionMode(pub Box<dyn PerceptionQuery>); + +impl Default for ActivePerceptionMode { + fn default() -> Self { + Self(Box::new(NaturalVision)) + } +} diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index d931da5fe..cb5d2935c 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -2,11 +2,14 @@ // Implements #404: server-side verb computation for context-sensitive [E] key // Spec: docs/design/interaction-verbs-v0.1.md // D-060: actions[] renamed to verbs[] across all surfaces +// +// Phase boundary: this system determines verb AVAILABILITY based on proximity +// and entity type only. Verb PRIORITY adjustment (e.g. POI flipping Observe +// above Talk) is a perception concern handled by the observer system. use bevy_ecs::prelude::*; use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption}; -use crate::knowledge::types::RelationshipState; -use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::knowledge::EntityRegistry; use crate::npc::Npc; use crate::simulation::movement::{PlayerCharacter, TilePosition}; @@ -23,13 +26,14 @@ pub struct Interactable; /// For each entity in range, determines available verbs sorted by priority. /// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot. /// -/// NOTE: Checks proximity only, not line-of-sight. The client filters -/// interaction prompts against visible entities. Server-side LOS filtering -/// is deferred until the interaction system can read the observer's visible set. +/// NOTE: Determines verb availability and default priority only. Relationship-based +/// priority adjustment (e.g. POI → Observe first) is applied by the observer +/// system after taking the buffer. This keeps the simulation phase free of +/// knowledge graph dependencies (D-010 phase boundary). #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( mut player_query: Query< - (&TilePosition, &KnowledgeGraph, &mut NearbyInteractionBuffer), + (&TilePosition, &mut NearbyInteractionBuffer), With<PlayerCharacter>, >, registry: Res<EntityRegistry>, @@ -38,7 +42,7 @@ pub fn compute_nearby_interactions( (With<Interactable>, Without<PlayerCharacter>), >, ) { - let Ok((player_pos, knowledge, mut buffer)) = player_query.single_mut() else { + let Ok((player_pos, mut buffer)) = player_query.single_mut() else { return; }; buffer.interactions.clear(); @@ -58,50 +62,26 @@ pub fn compute_nearby_interactions( EntityKind::Object }; - // Look up relationship state from knowledge graph - let relationship = if let Some(stable_id) = registry.to_stable(entity) { - knowledge.relationship_with(&stable_id) - } else { - RelationshipState::Unknown - }; - - let is_poi = relationship == RelationshipState::PersonOfInterest; let is_close = distance <= CLOSE_RANGE; - let mut verbs = Vec::new(); match entity_type { EntityKind::Npc => { if is_close { - if is_poi { - // Post-contradiction: Observe takes priority over Talk - verbs.push(VerbOption { - kind: VerbKind::ExamineNpc, - label: "Observe".into(), - priority: 1, - available: true, - }); - verbs.push(VerbOption { - kind: VerbKind::Talk, - label: "Talk".into(), - priority: 2, - available: true, - }); - } else { - // Default: Talk takes priority - verbs.push(VerbOption { - kind: VerbKind::Talk, - label: "Talk".into(), - priority: 1, - available: true, - }); - verbs.push(VerbOption { - kind: VerbKind::ExamineNpc, - label: "Observe".into(), - priority: 2, - available: true, - }); - } + // Default priority: Talk first, Observe second. + // Observer adjusts priority for POI entities. + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 2, + available: true, + }); } else { // Mid range: only Examine NPC (Talk requires close range) verbs.push(VerbOption { @@ -187,13 +167,13 @@ mod tests { world } - /// Spawn player with standard components + NearbyInteractionBuffer + /// Spawn player with standard components (no KnowledgeGraph — interaction + /// system doesn't access it; POI priority is handled by observer). fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { world .spawn(( PlayerCharacter, TilePosition::new(x, y, 0), - KnowledgeGraph::new(), NearbyInteractionBuffer::default(), )) .id() @@ -219,7 +199,9 @@ mod tests { assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 2); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); + assert_eq!(buffer.interactions[0].verbs[0].priority, 1); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[1].priority, 2); } #[test] @@ -236,6 +218,7 @@ mod tests { assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 1); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[0].priority, 1); } #[test] @@ -252,38 +235,6 @@ mod tests { assert!(buffer.interactions.is_empty()); } - #[test] - fn poi_npc_observe_takes_priority() { - let mut world = setup_world(); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((Npc, TilePosition::new(5, 6, 0), Interactable)) - .id(); - let npc_sid = registry.register(npc); - - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50); - kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - kg, - NearbyInteractionBuffer::default(), - )); - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_nearby_interactions); - schedule.run(&mut world); - - let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1); - assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); - assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk); - } - #[test] fn object_in_close_range_gets_examine() { let mut world = setup_world(); @@ -344,39 +295,6 @@ mod tests { assert!(buffer.interactions.is_empty()); } - #[test] - fn poi_npc_at_mid_range_gets_observe_only() { - let mut world = setup_world(); - let mut registry = EntityRegistry::new(0); - - let npc = world - .spawn((Npc, TilePosition::new(5, 9, 0), Interactable)) - .id(); - let npc_sid = registry.register(npc); - - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50); - kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - kg, - NearbyInteractionBuffer::default(), - )); - world.insert_resource(registry); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(compute_nearby_interactions); - schedule.run(&mut world); - - let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1); - assert_eq!(buffer.interactions[0].verbs.len(), 1); - assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); - assert_eq!(buffer.interactions[0].verbs[0].priority, 1); - } - #[test] fn equidistant_npcs_sorted_deterministically() { let mut world = setup_world(); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 5805cd3ec..492d2400a 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -31,12 +31,9 @@ impl Plugin for SimulationPlugin { pathfinding::compute_paths.after(input::process_player_input), path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), - interaction::compute_nearby_interactions - .after(movement::validate_movement), path_follow::cleanup_path_blocked.after(movement::validate_movement), time::advance_tick - .after(path_follow::cleanup_path_blocked) - .after(interaction::compute_nearby_interactions), + .after(path_follow::cleanup_path_blocked), ), ); From c05ff7b063b5e9398badf67467758ebc955fd6a1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 23:32:49 +0100 Subject: [PATCH 20/21] feat(client): enforce PROTOCOL_VERSION check in snapshot decode Client now rejects snapshots where version != PROTOCOL_VERSION (4). Returns null with error log on mismatch. Test snapshot updated to use Protocol.PROTOCOL_VERSION and v4 game_time format (tick_rate replaces paused field). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- client/scripts/autoloads/sim_bridge.gd | 4 ++-- client/scripts/protocol/protocol.gd | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 98aad66b8..48f530ea3 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -296,12 +296,12 @@ func _test_snapshot() -> Dictionary: return { "tick": _test_tick, - "version": 2, + "version": Protocol.PROTOCOL_VERSION, "game_time": { "day": 0, "time_of_day": _test_tick * 10, "day_phase": "Morning", - "paused": false, + "tick_rate": "Full", }, "player_facing": _test_facing, "entities": entities, diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index dad23ca0c..3879a879e 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -9,6 +9,10 @@ class_name Protocol ## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc") ## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"}) +## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. +## Reject snapshots where version != this value. +const PROTOCOL_VERSION: int = 4 + # -- Decode: bytes from server → GDScript types -------------------------------- @@ -27,6 +31,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: push_error("Protocol: snapshot missing required fields") return null + # Version check: reject snapshots from incompatible server + var version: Variant = raw.get("version") + if version != PROTOCOL_VERSION: + push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION]) + return null + var entities: Array[Dictionary] = [] var raw_entities: Array = raw["entities"] var dropped := 0 @@ -44,8 +54,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: # in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031). var tick: int = raw["tick"] - # v2 fields — optional for backward compatibility - var version: Variant = raw.get("version") + # version already checked above; game_time for HUD display var game_time: Variant = raw.get("game_time") # player_facing: FacingDirection is a unit enum → bare string in rmp_serde From c765572bea2864b2fb5225cb1994b2341de1d4ea Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 23:33:10 +0100 Subject: [PATCH 21/21] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a598e4c7..0f0ee736b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- PerceptionQuery trait and ActivePerceptionMode resource — abstraction layer for D-017 perception mode swapping (NaturalVision default implementation) +- VisibilityGeometry intermediate resource decoupling FOV computation from entity filtering +- Client-side PROTOCOL_VERSION enforcement — snapshot decoder rejects version mismatches with error log +- POI verb priority test in observer pipeline — asserts both verb kind and priority values end-to-end + +### Changed +- Observer pipeline decomposed into two-stage system: compute_visibility_geometry (geometry) → compute_observer_snapshot (entity filtering + assembly) +- POI verb priority adjustment moved from simulation phase (interaction.rs) to perception phase (observer) — fixes D-010 information boundary violation +- compute_nearby_interactions no longer reads KnowledgeGraph — determines verb availability by proximity only, verb priority adjusted by observer +- compute_nearby_interactions scheduling moved from SimulationPlugin to BridgePlugin for explicit ordering with geometry and observer systems +- Client test snapshot updated to v4 format (Protocol.PROTOCOL_VERSION, tick_rate replaces paused) + ### Added - Content validation tooling — `make validate-content` validates campaign YAML files against JSON schemas, maps files by directory context - Entity::to_bits() roundtrip test — guards against bevy version changes silently breaking wire IDs