Compare commits
61
Commits
776ba2e55f
...
v0.1.19
@@ -46,6 +46,14 @@
|
|||||||
"Bash(tea *)",
|
"Bash(tea *)",
|
||||||
"Bash(tooling/tea-comment *)",
|
"Bash(tooling/tea-comment *)",
|
||||||
|
|
||||||
|
"Bash(cargo test *)",
|
||||||
|
"Bash(cargo test)",
|
||||||
|
"Bash(cargo build *)",
|
||||||
|
"Bash(cargo build)",
|
||||||
|
"Bash(cargo check *)",
|
||||||
|
"Bash(cargo check)",
|
||||||
|
"Bash(tests/run-*)",
|
||||||
|
|
||||||
"Bash(chmod *)",
|
"Bash(chmod *)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
"Bash(find *)",
|
"Bash(find *)",
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review.
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 0. Branch guard — MUST be on `main`
|
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git branch --show-current
|
git branch --show-current
|
||||||
```
|
```
|
||||||
|
|
||||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||||
"PR reviews must be run from the `main` worktree. Switch to `main` first."
|
"PR reviews must be run by a Claude instance in the `main` worktree."
|
||||||
Do NOT proceed with the review from a team branch.
|
Do NOT proceed with the review. Do NOT work around this by reading files
|
||||||
|
from another worktree — the review agent itself must be running in main.
|
||||||
|
Stop and wait for the user to invoke `/pr-review` from main.
|
||||||
|
|
||||||
### 1. Determine the branch to review
|
### 1. Determine the branch to review
|
||||||
|
|
||||||
|
|||||||
@@ -288,24 +288,63 @@ Task(
|
|||||||
prompt: "You are on the {team} team for Sprint {N}.
|
prompt: "You are on the {team} team for Sprint {N}.
|
||||||
Branch: `{team}`
|
Branch: `{team}`
|
||||||
|
|
||||||
RULES:
|
RULES (NON-NEGOTIABLE):
|
||||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
|
||||||
checkout, branch, stash, tag, etc.). All git operations are
|
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||||
handled by the team lead.
|
checkout, branch, stash, tag, etc.). All git operations are
|
||||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
handled by the team lead. No exceptions.
|
||||||
the exact command with no wrappers or chaining. Examples:
|
|
||||||
db/connectors/ticket show 528
|
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||||
db/connectors/ticket list --sprint {N}
|
the exact command with no wrappers or chaining. Examples:
|
||||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
db/connectors/ticket show 528
|
||||||
add cleanup commands. Just the bare command.
|
db/connectors/ticket list --sprint {N}
|
||||||
|
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||||
|
add cleanup commands. Just the bare command.
|
||||||
|
|
||||||
|
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
|
||||||
|
Before creating a new file, Glob for similar files to learn
|
||||||
|
the existing patterns (naming, structure, imports). Follow
|
||||||
|
the conventions you find — do not invent new ones.
|
||||||
|
|
||||||
|
4. VERIFY AFTER WRITE: After implementing a change, grep for
|
||||||
|
all references to functions/properties/classes you modified
|
||||||
|
or removed. If you renamed, moved, or deleted something,
|
||||||
|
update EVERY call site. Missing a call site breaks tests
|
||||||
|
and blocks the team.
|
||||||
|
|
||||||
|
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
|
||||||
|
parts of the ticket are implemented. If the ticket says
|
||||||
|
'deliver A, B, and C', all three must exist and work. If
|
||||||
|
you cannot complete part of a task, message the team lead
|
||||||
|
explaining what is blocked and what remains — do NOT mark
|
||||||
|
it completed.
|
||||||
|
|
||||||
|
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
|
||||||
|
in 3 attempts, stop and message the team lead immediately.
|
||||||
|
Do not silently skip work or leave stubs. Do not move to
|
||||||
|
the next task while the current one is incomplete.
|
||||||
|
|
||||||
|
7. BACKWARD COMPATIBILITY: When extracting, moving, or
|
||||||
|
refactoring code, ensure all existing consumers still work.
|
||||||
|
Add proxy methods/properties if needed. Grep for the old
|
||||||
|
name to find every call site.
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
|
||||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||||
2. Read the decision files referenced in the briefing.
|
2. Read the decision files referenced in the briefing.
|
||||||
3. Check TaskList for available work.
|
3. Check TaskList for available work.
|
||||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||||
mark it in_progress, and implement it.
|
mark it in_progress, and implement it.
|
||||||
5. When done, mark the task completed and check TaskList for
|
5. Before marking done, verify:
|
||||||
the next available task.
|
- All deliverables from the ticket exist (not just some)
|
||||||
|
- No broken references (grep for changed names/signatures)
|
||||||
|
- New files follow existing naming and directory conventions
|
||||||
|
- Modified files still parse (no syntax errors)
|
||||||
|
6. Mark the task completed and check TaskList for the next
|
||||||
|
available task.
|
||||||
|
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||||
|
on your own.
|
||||||
|
|
||||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||||
description: "Sprint {N} {team}: {name}",
|
description: "Sprint {N} {team}: {name}",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ run_check() {
|
|||||||
|
|
||||||
# --- Checks ---
|
# --- Checks ---
|
||||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||||
|
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||||
|
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ Thumbs.db
|
|||||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||||
.claude/plans/
|
.claude/plans/
|
||||||
.claude/projects/
|
.claude/projects/
|
||||||
|
.claude/agent-memory/
|
||||||
|
|||||||
@@ -6,12 +6,61 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.1.19] — 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||||
|
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||||
|
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||||
|
- CI PR #69 merged — Sprint 19 test runners, IPC fixtures, protocol handshake, benchmark (4 tickets, 1297 lines)
|
||||||
|
- Test runner scripts — 7 bash scripts (run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-ipc-benchmark, run-all) with structured JSON output (#270, D-030)
|
||||||
|
- IPC serialization fixtures — 5 msgpack fixtures with Rust generator, cross-language GDScript validation (22 assertions) (#271, D-030)
|
||||||
|
- Protocol handshake client — HANDSHAKING state in SimBridge, HandshakeMessage decode with 5s timeout (#556, D-020)
|
||||||
|
- IPC round-trip benchmark — p50/p95/p99 latency reporting, 5ms threshold (#342, D-020)
|
||||||
|
- Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020)
|
||||||
|
- Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation
|
||||||
|
- State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026)
|
||||||
|
- Scope tag system — `ScopeTagKind` (Neighborhood, ActiveQuest, Colleague, KnownContact), `ScopePinned` marker, automatic assignment from KnowledgeGraph and RelationshipGraph (#98, D-026)
|
||||||
|
- Timestamp-based eviction — `LastInteractionTick` LRU tracking, `SimSpacePressure` resource, BinaryHeap eviction respecting scope-pinned entities, Active cap 80 (#97, D-026)
|
||||||
|
- Save/load ECS extraction — `save_to_file`/`load_from_file` via MessagePack, `SaveGame`/`LoadGame` IPC commands, `SaveLoadResultWire` on ObserverSnapshot (#553, D-085)
|
||||||
|
- ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks
|
||||||
|
- Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200)
|
||||||
|
- Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010)
|
||||||
|
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
|
||||||
|
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
|
||||||
|
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
|
||||||
|
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
|
||||||
|
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
|
||||||
|
- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API
|
||||||
|
- Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review
|
||||||
|
- D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme)
|
||||||
|
- Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology)
|
||||||
|
- Decision ID claim system — `db/connectors/decision` CLI with `next`, `claim`, `check-dupes` commands to prevent cross-worktree D/Q/R ID collisions, pre-commit duplicate check
|
||||||
|
- D-085: Per-game save directory structure — every new game creates `user://saves/<game-id>/`, F5 quicksave, F6 quickload
|
||||||
|
- Q-029: Save file format design — long-term considerations for versioning, compression, integrity, metadata headers
|
||||||
|
- D-086: Renumbered insert icon system (was D-084 on visual branch) to resolve cross-worktree ID collision
|
||||||
|
- Save/load wireframe updated for D-085 — LOAD tab shows games grouped by directory with expand/collapse, QUICKSAVE slot, F5/F6 hints
|
||||||
|
- Sprint 19: Persist planned — 16 tickets (server 7, client 5, CI 4) covering save/load, tier eviction/scope, test infrastructure
|
||||||
|
- Character creation & game setup workshop brief — covers creation model, seed boundary, gate activation, quest seeding, game toggles (resolves Q-011)
|
||||||
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
||||||
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
||||||
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
||||||
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||||
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||||
|
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
|
||||||
|
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
|
||||||
|
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||||
|
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||||
|
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||||
|
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||||
|
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||||
|
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||||
|
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||||
|
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||||
|
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||||
|
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||||
|
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||||
|
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||||
@@ -26,6 +75,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||||
|
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||||
@@ -40,6 +90,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field)
|
||||||
|
- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant
|
||||||
|
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||||
|
|||||||
@@ -56,12 +56,14 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||||
|
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||||
|
|
||||||
### File conventions
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||||
|
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- Briefings: one per agent, updated after decision-producing rounds
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
|||||||
pre-pr-server pre-pr-client pre-pr-content \
|
pre-pr-server pre-pr-client pre-pr-content \
|
||||||
fixtures-client golden-diff golden-update \
|
fixtures-client golden-diff golden-update \
|
||||||
checklist-validate checklist-generate \
|
checklist-validate checklist-generate \
|
||||||
perf-baseline debug-schedule
|
perf-baseline debug-schedule \
|
||||||
|
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
|
|
||||||
@@ -23,11 +24,15 @@ help:
|
|||||||
@echo " make stop Stop any running server instance"
|
@echo " make stop Stop any running server instance"
|
||||||
@echo " make client Run the Godot client (test mode)"
|
@echo " make client Run the Godot client (test mode)"
|
||||||
@echo " make server Run the Rust simulation server"
|
@echo " make server Run the Rust simulation server"
|
||||||
@echo " make test Run all tests"
|
@echo " make test Run all tests"
|
||||||
@echo " make lint Run all linters"
|
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
|
||||||
@echo " make ci Run full CI pipeline locally"
|
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
||||||
@echo " make ci-client Run client CI checks"
|
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
|
||||||
@echo " make ci-server Run server CI checks"
|
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
|
||||||
|
@echo " make lint Run all linters"
|
||||||
|
@echo " make ci Run full CI pipeline locally"
|
||||||
|
@echo " make ci-client Run client CI checks"
|
||||||
|
@echo " make ci-server Run server CI checks"
|
||||||
@echo " make check-protocol Verify server/client protocol versions match"
|
@echo " make check-protocol Verify server/client protocol versions match"
|
||||||
@echo " make clean Remove build artifacts and caches"
|
@echo " make clean Remove build artifacts and caches"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -128,7 +133,7 @@ stop:
|
|||||||
test: test-server test-client
|
test: test-server test-client
|
||||||
|
|
||||||
test-server:
|
test-server:
|
||||||
cd server && cargo nextest run
|
tests/run-rust
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
cd server && cargo test --test gen_fixtures -- --ignored
|
cd server && cargo test --test gen_fixtures -- --ignored
|
||||||
@@ -169,8 +174,19 @@ golden-update:
|
|||||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||||
|
|
||||||
test-client:
|
test-client:
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
tests/run-godot
|
||||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
|
||||||
|
test-ipc-fixtures:
|
||||||
|
tests/run-ipc-fixtures
|
||||||
|
|
||||||
|
test-ipc-protocol:
|
||||||
|
tests/run-ipc-protocol
|
||||||
|
|
||||||
|
test-ipc-integration:
|
||||||
|
tests/run-ipc-integration
|
||||||
|
|
||||||
|
test-ipc-benchmark:
|
||||||
|
tests/run-ipc-benchmark
|
||||||
|
|
||||||
# --- Lint ---
|
# --- Lint ---
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ notifications:
|
|||||||
|
|
||||||
# System
|
# System
|
||||||
save_complete: "Progress saved."
|
save_complete: "Progress saved."
|
||||||
|
load_complete: "Session restored."
|
||||||
|
save_failed: "Save failed."
|
||||||
|
load_failed: "Load failed."
|
||||||
connection_lost: "Signal interrupted."
|
connection_lost: "Signal interrupted."
|
||||||
connection_restored: "Signal restored."
|
connection_restored: "Signal restored."
|
||||||
|
|
||||||
@@ -170,6 +173,8 @@ dialogue:
|
|||||||
menu:
|
menu:
|
||||||
pause_title: "Paused"
|
pause_title: "Paused"
|
||||||
resume: "Resume"
|
resume: "Resume"
|
||||||
|
new_game: "New Game"
|
||||||
|
continue: "Continue"
|
||||||
settings: "Settings"
|
settings: "Settings"
|
||||||
save_game: "Save"
|
save_game: "Save"
|
||||||
load_game: "Load"
|
load_game: "Load"
|
||||||
|
|||||||
+12
-1
@@ -11,7 +11,7 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="The Settled Reach"
|
config/name="The Settled Reach"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main_menu.tscn"
|
||||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
|||||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||||
|
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||||
|
|
||||||
[audio]
|
[audio]
|
||||||
|
|
||||||
@@ -135,6 +136,16 @@ teleport_hub={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
quicksave={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
quickload={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||||
|
|
||||||
|
; Main menu — New Game / Continue / Quit.
|
||||||
|
; #258: D-085 per-game save directory created on New Game.
|
||||||
|
|
||||||
|
[node name="MainMenu" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
script = ExtResource("1_mainmenu")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -120.0
|
||||||
|
offset_top = -80.0
|
||||||
|
offset_right = 120.0
|
||||||
|
offset_bottom = 100.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "THE SETTLED REACH"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
theme_override_font_sizes/font_size = 36
|
||||||
|
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||||
|
|
||||||
|
[node name="Spacer" type="Control" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 24)
|
||||||
|
|
||||||
|
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "NEW GAME"
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||||
|
|
||||||
|
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "CONTINUE"
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||||
|
|
||||||
|
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "QUIT"
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
|
signal game_id_changed(new_id: String)
|
||||||
|
|
||||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||||
# Tiles use format: [{x, y, z, type}].
|
# Tiles use format: [{x, y, z, type}].
|
||||||
var current_snapshot: Dictionary = {}
|
var current_snapshot: Dictionary = {}
|
||||||
|
|
||||||
|
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||||
|
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||||
|
# Empty string when no session is active (main menu state).
|
||||||
|
var current_game_id: String = "":
|
||||||
|
set(v):
|
||||||
|
current_game_id = v
|
||||||
|
game_id_changed.emit(v)
|
||||||
var current_tick: int = 0
|
var current_tick: int = 0
|
||||||
var player_position: Vector2 = Vector2.ZERO
|
var player_position: Vector2 = Vector2.ZERO
|
||||||
var visible_entities: Array = []
|
var visible_entities: Array = []
|
||||||
@@ -60,6 +70,11 @@ var insert_active: bool = true
|
|||||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||||
var rng_seed: Variant = null
|
var rng_seed: Variant = null
|
||||||
|
|
||||||
|
# v15 fields (#554, D-085): save/load result from server.
|
||||||
|
# {success: bool, kind: "save"|"load", error: Variant} or null.
|
||||||
|
# One-shot: consumed by main.gd after display, then set back to null.
|
||||||
|
var save_result: Variant = null
|
||||||
|
|
||||||
# v7 fields (#431, D-059/D-060)
|
# v7 fields (#431, D-059/D-060)
|
||||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||||
|
|
||||||
@@ -269,6 +284,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
else:
|
else:
|
||||||
current_examine_result = null
|
current_examine_result = null
|
||||||
|
|
||||||
|
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
|
||||||
|
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||||
|
save_result = snapshot.save_result
|
||||||
|
else:
|
||||||
|
save_result = null
|
||||||
|
|
||||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||||
# Only update when field is present (null means no change, server sends when KG changes).
|
# Only update when field is present (null means no change, server sends when KG changes).
|
||||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ enum Action {
|
|||||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||||
SET_FACING, # D-054: facing octant update (no movement)
|
SET_FACING, # D-054: facing octant update (no movement)
|
||||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||||
|
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||||
|
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||||
}
|
}
|
||||||
|
|
||||||
var input_queue: Array[Dictionary] = []
|
var input_queue: Array[Dictionary] = []
|
||||||
@@ -112,12 +114,24 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
elif event.is_action_pressed("teleport_hub"):
|
elif event.is_action_pressed("teleport_hub"):
|
||||||
if GameState.gauntlet_mode:
|
if GameState.gauntlet_mode:
|
||||||
action = Action.TELEPORT_HUB
|
action = Action.TELEPORT_HUB
|
||||||
|
elif event.is_action_pressed("quicksave"):
|
||||||
|
action = Action.SAVE_GAME
|
||||||
|
elif event.is_action_pressed("quickload"):
|
||||||
|
action = Action.LOAD_GAME
|
||||||
|
|
||||||
if action != -1:
|
if action != -1:
|
||||||
input_queue.append({
|
var entry := {
|
||||||
"action": action,
|
"action": action,
|
||||||
"timestamp_msec": Time.get_ticks_msec(),
|
"timestamp_msec": Time.get_ticks_msec(),
|
||||||
})
|
}
|
||||||
|
# #554: Attach save path for SaveGame/LoadGame actions
|
||||||
|
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
|
||||||
|
var game_id := GameState.current_game_id
|
||||||
|
if game_id.is_empty():
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return # No active session — ignore save/load
|
||||||
|
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
|
||||||
|
input_queue.append(entry)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
extends Node
|
||||||
|
## D-085 (#258): Game session lifecycle manager.
|
||||||
|
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||||
|
## and handles quit-to-menu flow with save confirmation.
|
||||||
|
##
|
||||||
|
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||||
|
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||||
|
|
||||||
|
const SAVES_DIR := "user://saves/"
|
||||||
|
const GAME_SCENE := "res://scenes/main.tscn"
|
||||||
|
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||||
|
|
||||||
|
var _quit_dialog: ConfirmationDialog = null
|
||||||
|
|
||||||
|
|
||||||
|
## Generate a new game-id, create its save directory, and activate the session.
|
||||||
|
## Returns the new game-id string.
|
||||||
|
func new_game() -> String:
|
||||||
|
var now := Time.get_datetime_dict_from_system()
|
||||||
|
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||||
|
now.year, now.month, now.day,
|
||||||
|
now.hour, now.minute, now.second,
|
||||||
|
]
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||||
|
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||||
|
var save_path := SAVES_DIR + game_id + "/"
|
||||||
|
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||||
|
if err != OK:
|
||||||
|
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||||
|
save_path, error_string(err)])
|
||||||
|
return ""
|
||||||
|
GameState.current_game_id = game_id
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
## Resume an existing game session by setting the active game-id.
|
||||||
|
func resume_game(game_id: String) -> void:
|
||||||
|
GameState.current_game_id = game_id
|
||||||
|
|
||||||
|
|
||||||
|
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||||
|
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||||
|
func list_game_dirs() -> Array:
|
||||||
|
var dir := DirAccess.open(SAVES_DIR)
|
||||||
|
if dir == null:
|
||||||
|
return []
|
||||||
|
var results: Array = []
|
||||||
|
dir.list_dir_begin()
|
||||||
|
var entry := dir.get_next()
|
||||||
|
while entry != "":
|
||||||
|
if dir.current_is_dir() and not entry.begins_with("."):
|
||||||
|
var dir_path := SAVES_DIR + entry + "/"
|
||||||
|
var newest_save := _find_newest_save(dir_path)
|
||||||
|
var mtime: int = 0
|
||||||
|
if newest_save != "":
|
||||||
|
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||||
|
results.append({
|
||||||
|
"game_id": entry,
|
||||||
|
"modified_time": mtime,
|
||||||
|
"newest_save": newest_save,
|
||||||
|
})
|
||||||
|
entry = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
|
return a.modified_time > b.modified_time)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||||
|
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||||
|
func quit_to_menu() -> void:
|
||||||
|
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||||
|
return # Dialog already open
|
||||||
|
_quit_dialog = ConfirmationDialog.new()
|
||||||
|
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||||
|
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||||
|
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||||
|
get_tree().root.add_child(_quit_dialog)
|
||||||
|
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||||
|
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||||
|
_quit_dialog.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func _do_quit_to_menu() -> void:
|
||||||
|
_cleanup_quit_dialog()
|
||||||
|
# #554: Trigger quicksave before navigating to menu.
|
||||||
|
# send_input() buffers the command — defer scene change by one frame so
|
||||||
|
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||||
|
if not GameState.current_game_id.is_empty():
|
||||||
|
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||||
|
SimBridge.send_input({
|
||||||
|
"action": InputMapper.Action.SAVE_GAME,
|
||||||
|
"timestamp_msec": Time.get_ticks_msec(),
|
||||||
|
"action_data": {"path": path},
|
||||||
|
})
|
||||||
|
GameState.current_game_id = ""
|
||||||
|
_navigate_to_menu.call_deferred()
|
||||||
|
else:
|
||||||
|
GameState.current_game_id = ""
|
||||||
|
get_tree().change_scene_to_file(MENU_SCENE)
|
||||||
|
|
||||||
|
|
||||||
|
func _navigate_to_menu() -> void:
|
||||||
|
get_tree().change_scene_to_file(MENU_SCENE)
|
||||||
|
|
||||||
|
|
||||||
|
func _cleanup_quit_dialog() -> void:
|
||||||
|
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||||
|
_quit_dialog.queue_free()
|
||||||
|
_quit_dialog = null
|
||||||
|
|
||||||
|
|
||||||
|
func _find_newest_save(dir_path: String) -> String:
|
||||||
|
var dir := DirAccess.open(dir_path)
|
||||||
|
if dir == null:
|
||||||
|
return ""
|
||||||
|
var best_name := ""
|
||||||
|
var best_time: int = 0
|
||||||
|
dir.list_dir_begin()
|
||||||
|
var entry := dir.get_next()
|
||||||
|
while entry != "":
|
||||||
|
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||||
|
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||||
|
if mtime > best_time:
|
||||||
|
best_time = mtime
|
||||||
|
best_name = entry
|
||||||
|
entry = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
return best_name
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
# Connection states
|
# Connection states
|
||||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||||
|
|
||||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||||
var _test_tick: int = 0
|
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||||
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
|
||||||
var _test_facing: String = "North"
|
|
||||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
|
||||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
|
||||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
|
||||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
|
||||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||||
|
|
||||||
@@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
|||||||
var _connect_retries: int = 0
|
var _connect_retries: int = 0
|
||||||
var _retry_timer: float = 0.0
|
var _retry_timer: float = 0.0
|
||||||
|
|
||||||
|
# Handshake state (#556)
|
||||||
|
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||||
|
var _handshake_start_usec: int = 0
|
||||||
|
|
||||||
# Signals
|
# Signals
|
||||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||||
signal snapshot_received(snapshot: Dictionary)
|
signal snapshot_received(snapshot: Dictionary)
|
||||||
|
signal handshake_complete(protocol_version: int)
|
||||||
|
signal handshake_failed(reason: String)
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if test_mode:
|
if test_mode:
|
||||||
|
harness = TestHarness.new()
|
||||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||||
|
|
||||||
# Reset test state — call before tests that use _test_snapshot()
|
|
||||||
|
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||||
|
|
||||||
func reset_test_state() -> void:
|
func reset_test_state() -> void:
|
||||||
_test_tick = 0
|
if harness: harness.reset()
|
||||||
_test_player_pos = Vector2i(10, 10)
|
|
||||||
_test_facing = "North"
|
func _test_snapshot() -> Dictionary:
|
||||||
_test_input_queue.clear()
|
return harness.snapshot()
|
||||||
_test_in_dialogue = false
|
|
||||||
_test_gauntlet_mode = false
|
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||||
_test_npc_relationship = "Unknown"
|
return harness.has_los(from, to)
|
||||||
|
|
||||||
|
var _test_tick: int:
|
||||||
|
get: return harness.tick if harness else 0
|
||||||
|
set(v):
|
||||||
|
if harness: harness.tick = v
|
||||||
|
|
||||||
|
var _test_player_pos: Vector2i:
|
||||||
|
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||||
|
set(v):
|
||||||
|
if harness: harness.player_pos = v
|
||||||
|
|
||||||
|
var _test_facing: String:
|
||||||
|
get: return harness.facing if harness else "North"
|
||||||
|
set(v):
|
||||||
|
if harness: harness.facing = v
|
||||||
|
|
||||||
|
var _test_in_dialogue: bool:
|
||||||
|
get: return harness.in_dialogue if harness else false
|
||||||
|
set(v):
|
||||||
|
if harness: harness.in_dialogue = v
|
||||||
|
|
||||||
|
var _test_gauntlet_mode: bool:
|
||||||
|
get: return harness.gauntlet_mode if harness else false
|
||||||
|
set(v):
|
||||||
|
if harness: harness.gauntlet_mode = v
|
||||||
|
|
||||||
|
var _test_npc_relationship: String:
|
||||||
|
get: return harness.npc_relationship if harness else "Unknown"
|
||||||
|
set(v):
|
||||||
|
if harness: harness.npc_relationship = v
|
||||||
|
|
||||||
|
var _test_input_queue: Array:
|
||||||
|
get: return harness.input_queue if harness else []
|
||||||
|
|
||||||
|
|
||||||
|
# -- Connection lifecycle ------------------------------------------------------
|
||||||
|
|
||||||
# Change connection state and emit signal
|
# Change connection state and emit signal
|
||||||
func _set_state(new_state: ConnectionState) -> void:
|
func _set_state(new_state: ConnectionState) -> void:
|
||||||
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
|
|||||||
# Spawn server subprocess
|
# Spawn server subprocess
|
||||||
if not server_path.is_empty():
|
if not server_path.is_empty():
|
||||||
_server = ServerProcess.new()
|
_server = ServerProcess.new()
|
||||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||||
|
var args := ["127.0.0.1:" + str(server_port)]
|
||||||
|
var game_id: String = GameState.current_game_id
|
||||||
|
if not game_id.is_empty():
|
||||||
|
args.append_array(["--game-id", game_id])
|
||||||
|
var pid := _server.start(server_path, args)
|
||||||
if pid <= 0:
|
if pid <= 0:
|
||||||
push_error("SimBridge: failed to start server")
|
push_error("SimBridge: failed to start server")
|
||||||
_set_state(ConnectionState.ERROR)
|
_set_state(ConnectionState.ERROR)
|
||||||
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
|
|||||||
_bridge.poll()
|
_bridge.poll()
|
||||||
match _bridge.get_status():
|
match _bridge.get_status():
|
||||||
StreamPeerTCP.STATUS_CONNECTED:
|
StreamPeerTCP.STATUS_CONNECTED:
|
||||||
_set_state(ConnectionState.CONNECTED)
|
_handshake_start_usec = Time.get_ticks_usec()
|
||||||
|
_set_state(ConnectionState.HANDSHAKING)
|
||||||
StreamPeerTCP.STATUS_CONNECTING:
|
StreamPeerTCP.STATUS_CONNECTING:
|
||||||
pass # Still connecting, wait
|
pass # Still connecting, wait
|
||||||
StreamPeerTCP.STATUS_ERROR:
|
StreamPeerTCP.STATUS_ERROR:
|
||||||
@@ -134,6 +179,62 @@ func _process(delta: float) -> void:
|
|||||||
_bridge = null # Reset and retry
|
_bridge = null # Reset and retry
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
|
||||||
|
if state == ConnectionState.HANDSHAKING:
|
||||||
|
if _bridge == null:
|
||||||
|
_set_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
_bridge.poll()
|
||||||
|
|
||||||
|
# Check connection dropped during handshake
|
||||||
|
var bridge_status := _bridge.get_status()
|
||||||
|
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||||
|
var reason := "Connection dropped during handshake"
|
||||||
|
push_error("SimBridge: %s" % reason)
|
||||||
|
handshake_failed.emit(reason)
|
||||||
|
_bridge = null
|
||||||
|
_set_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check timeout
|
||||||
|
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
|
||||||
|
var reason := "Handshake timeout: no message received within 5 seconds"
|
||||||
|
push_error("SimBridge: %s" % reason)
|
||||||
|
handshake_failed.emit(reason)
|
||||||
|
_bridge.disconnect_from_server()
|
||||||
|
_set_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to read first message
|
||||||
|
var msg := _bridge.poll_message()
|
||||||
|
if msg.is_empty():
|
||||||
|
return # Not ready yet, continue polling
|
||||||
|
|
||||||
|
# Decode HandshakeMessage: { "protocol_version": N }
|
||||||
|
var decoded: Variant = Messagepack.decode(msg)
|
||||||
|
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||||
|
or not decoded.value.has("protocol_version"):
|
||||||
|
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||||
|
push_error("SimBridge: %s" % reason)
|
||||||
|
handshake_failed.emit(reason)
|
||||||
|
_bridge.disconnect_from_server()
|
||||||
|
_set_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
var server_version: int = decoded.value["protocol_version"]
|
||||||
|
if server_version != Protocol.PROTOCOL_VERSION:
|
||||||
|
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||||
|
server_version, Protocol.PROTOCOL_VERSION]
|
||||||
|
push_error("SimBridge: %s" % reason)
|
||||||
|
handshake_failed.emit(reason)
|
||||||
|
_bridge.disconnect_from_server()
|
||||||
|
_set_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
handshake_complete.emit(server_version)
|
||||||
|
_set_state(ConnectionState.CONNECTED)
|
||||||
|
return
|
||||||
|
|
||||||
if _bridge == null:
|
if _bridge == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -170,9 +271,13 @@ func _process(delta: float) -> void:
|
|||||||
push_warning("SimBridge: connection lost")
|
push_warning("SimBridge: connection lost")
|
||||||
_set_state(ConnectionState.DISCONNECTED)
|
_set_state(ConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Input / snapshot ----------------------------------------------------------
|
||||||
|
|
||||||
# Send input to simulation server.
|
# Send input to simulation server.
|
||||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||||
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
|
# In test mode, inputs are delegated to the test harness.
|
||||||
|
# In live mode, encoded and buffered for transport.
|
||||||
# Returns OK on success, or an error code on failure.
|
# Returns OK on success, or an error code on failure.
|
||||||
func send_input(player_input: Dictionary) -> Error:
|
func send_input(player_input: Dictionary) -> Error:
|
||||||
if state != ConnectionState.CONNECTED:
|
if state != ConnectionState.CONNECTED:
|
||||||
@@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error:
|
|||||||
var wire_name: String = action_enum_to_wire(action)
|
var wire_name: String = action_enum_to_wire(action)
|
||||||
if not wire_name.is_empty():
|
if not wire_name.is_empty():
|
||||||
if wire_name == "SetFacing":
|
if wire_name == "SetFacing":
|
||||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
|
||||||
var facing: String = ""
|
var facing: String = ""
|
||||||
var action_data: Variant = player_input.get("action_data")
|
var action_data: Variant = player_input.get("action_data")
|
||||||
if action_data is Dictionary:
|
if action_data is Dictionary:
|
||||||
facing = str(action_data.get("facing", ""))
|
facing = str(action_data.get("facing", ""))
|
||||||
if not facing.is_empty():
|
if not facing.is_empty():
|
||||||
_test_facing = facing
|
harness.process_facing(facing)
|
||||||
else:
|
else:
|
||||||
_test_input_queue.append(wire_name)
|
harness.process_input(wire_name)
|
||||||
return OK
|
return OK
|
||||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||||
if action_name.is_empty():
|
if action_name.is_empty():
|
||||||
# action_enum_to_wire already emits push_warning for invalid actions
|
|
||||||
return ERR_INVALID_PARAMETER
|
return ERR_INVALID_PARAMETER
|
||||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
|
||||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
|
||||||
var tick: int = GameState.current_tick
|
var tick: int = GameState.current_tick
|
||||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
|
||||||
var action_data: Variant = player_input.get("action_data")
|
var action_data: Variant = player_input.get("action_data")
|
||||||
if action_data != null:
|
if action_data != null:
|
||||||
entry["action_data"] = action_data
|
entry["action_data"] = action_data
|
||||||
@@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error:
|
|||||||
return OK
|
return OK
|
||||||
|
|
||||||
# Poll for snapshot from simulation.
|
# Poll for snapshot from simulation.
|
||||||
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
|
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||||
func poll_snapshot() -> Variant:
|
func poll_snapshot() -> Variant:
|
||||||
if state != ConnectionState.CONNECTED:
|
if state != ConnectionState.CONNECTED:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
if test_mode:
|
if test_mode:
|
||||||
var snapshot = _test_snapshot()
|
var snapshot = harness.snapshot()
|
||||||
snapshot_received.emit(snapshot)
|
snapshot_received.emit(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@@ -252,6 +352,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
|||||||
if old_conv_ended.size() > 0:
|
if old_conv_ended.size() > 0:
|
||||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
||||||
|
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
|
||||||
|
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
|
||||||
|
snapshot["save_result"] = _last_snapshot["save_result"]
|
||||||
_last_snapshot = snapshot
|
_last_snapshot = snapshot
|
||||||
|
|
||||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||||
@@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]:
|
|||||||
_outbound_buffer.clear()
|
_outbound_buffer.clear()
|
||||||
return inputs
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
# -- Wire protocol mapping -----------------------------------------------------
|
||||||
|
|
||||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||||
static func action_enum_to_wire(action: int) -> String:
|
static func action_enum_to_wire(action: int) -> String:
|
||||||
@@ -286,319 +392,10 @@ static func action_enum_to_wire(action: int) -> String:
|
|||||||
return "SetFacing" # D-054: facing octant update (no movement)
|
return "SetFacing" # D-054: facing octant update (no movement)
|
||||||
InputMapper.Action.TELEPORT_HUB:
|
InputMapper.Action.TELEPORT_HUB:
|
||||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||||
|
InputMapper.Action.SAVE_GAME:
|
||||||
|
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||||
|
InputMapper.Action.LOAD_GAME:
|
||||||
|
return "LoadGame" # #554: F6 quickload (D-085)
|
||||||
_:
|
_:
|
||||||
push_warning("SimBridge: unknown action enum %s" % action)
|
push_warning("SimBridge: unknown action enum %s" % action)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Dynamic test snapshot — processes queued inputs to move player, generates
|
|
||||||
# visibility based on current position. Matches Protocol.decode_snapshot() format.
|
|
||||||
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
|
|
||||||
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
|
|
||||||
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
|
|
||||||
# map layout and can exercise the rendering pipeline independently.
|
|
||||||
func _test_snapshot() -> Dictionary:
|
|
||||||
_test_tick += 1
|
|
||||||
|
|
||||||
# Process queued inputs
|
|
||||||
for action_name in _test_input_queue:
|
|
||||||
if action_name == "TeleportToHub":
|
|
||||||
# #501: Reset to hub spawn position, clear dialogue
|
|
||||||
_test_player_pos = Vector2i(10, 10)
|
|
||||||
_test_in_dialogue = false
|
|
||||||
continue
|
|
||||||
if action_name == "Interact":
|
|
||||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
|
||||||
var npc_pos := Vector2i(12, 9)
|
|
||||||
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
|
|
||||||
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
|
|
||||||
_test_in_dialogue = true
|
|
||||||
continue
|
|
||||||
var delta := _action_to_delta(action_name)
|
|
||||||
var new_pos := _test_player_pos + delta
|
|
||||||
if _test_is_walkable(new_pos):
|
|
||||||
_test_player_pos = new_pos
|
|
||||||
if delta != Vector2i.ZERO:
|
|
||||||
# Walk-away dismisses dialogue (D-064)
|
|
||||||
if _test_in_dialogue:
|
|
||||||
_test_in_dialogue = false
|
|
||||||
_test_input_queue.clear()
|
|
||||||
|
|
||||||
var px := _test_player_pos.x
|
|
||||||
var py := _test_player_pos.y
|
|
||||||
|
|
||||||
# Build entities — player always visible
|
|
||||||
var entities: Array = [{
|
|
||||||
"entity_id": 1,
|
|
||||||
"x": float(px),
|
|
||||||
"y": float(py),
|
|
||||||
"z": 0,
|
|
||||||
"kind": { "variant": "Player", "data": null },
|
|
||||||
"visibility": "Forward",
|
|
||||||
}]
|
|
||||||
|
|
||||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
|
||||||
var npc_pos := Vector2i(12, 9)
|
|
||||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
|
||||||
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
|
|
||||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
|
||||||
entities.append({
|
|
||||||
"entity_id": 2,
|
|
||||||
"x": float(npc_pos.x),
|
|
||||||
"y": float(npc_pos.y),
|
|
||||||
"z": 0,
|
|
||||||
"kind": { "variant": "Npc", "data": null },
|
|
||||||
"visibility": sector,
|
|
||||||
"relationship": _test_npc_relationship,
|
|
||||||
})
|
|
||||||
|
|
||||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
|
||||||
var nearby: Array = []
|
|
||||||
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
|
||||||
nearby.append({
|
|
||||||
"entity_id": 2,
|
|
||||||
"entity_type": "Npc",
|
|
||||||
"distance": npc_dist,
|
|
||||||
"verbs": [
|
|
||||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
|
||||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
# v5: monologue on first tick (#414)
|
|
||||||
var monologue: Variant = null
|
|
||||||
if _test_tick == 1:
|
|
||||||
monologue = {
|
|
||||||
"id": "test_enter_001",
|
|
||||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
|
||||||
"duration_seconds": 5.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
|
|
||||||
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
|
|
||||||
# Movement (walk-away) clears it. Client consume-once guards against re-show.
|
|
||||||
# Options: structured {text, response_id, priority} per #435.
|
|
||||||
var dialogue: Variant = null
|
|
||||||
if _test_in_dialogue:
|
|
||||||
dialogue = {
|
|
||||||
"npc_name": "Kael",
|
|
||||||
"npc_entity_id": 2,
|
|
||||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
|
||||||
"options": [
|
|
||||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
|
||||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
|
||||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
|
|
||||||
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
|
|
||||||
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
|
|
||||||
var pending_recs: Array = []
|
|
||||||
var cycle_pos := _test_tick % 12
|
|
||||||
if cycle_pos < 6:
|
|
||||||
var total_delay := 6
|
|
||||||
var remaining := total_delay - cycle_pos
|
|
||||||
pending_recs.append({
|
|
||||||
"entity_id": 100,
|
|
||||||
"x": 13.5,
|
|
||||||
"y": 12.5,
|
|
||||||
"z": 0,
|
|
||||||
"remaining_ticks": remaining,
|
|
||||||
"total_delay_ticks": total_delay,
|
|
||||||
})
|
|
||||||
|
|
||||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
|
||||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
|
||||||
# Conversation ends after 6 exchanges (~30 ticks).
|
|
||||||
var conv_events: Array = []
|
|
||||||
var conv_ended: Array = []
|
|
||||||
var conv_start := 3
|
|
||||||
var conv_lines := [
|
|
||||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
|
||||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
|
||||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
|
||||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
|
||||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
|
||||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
|
||||||
]
|
|
||||||
var conv_tick_interval := 5
|
|
||||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
|
||||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
|
||||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
|
||||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
|
||||||
if within_tick == 0 and conv_index < conv_lines.size():
|
|
||||||
var cl: Dictionary = conv_lines[conv_index]
|
|
||||||
conv_events.append({
|
|
||||||
"speaker_id": 10,
|
|
||||||
"target_id": 11,
|
|
||||||
"speaker_name": cl.speaker,
|
|
||||||
"target_name": cl.target,
|
|
||||||
"occluded_line": cl.line,
|
|
||||||
})
|
|
||||||
elif _test_tick == conv_start + conv_total_ticks:
|
|
||||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tick": _test_tick,
|
|
||||||
"version": Protocol.PROTOCOL_VERSION,
|
|
||||||
"game_time": {
|
|
||||||
"day": 0,
|
|
||||||
"time_of_day": _test_tick * 10,
|
|
||||||
"day_phase": "Morning",
|
|
||||||
"tick_rate": "Full",
|
|
||||||
},
|
|
||||||
"player_facing": _test_facing,
|
|
||||||
"player_stance": "Walk",
|
|
||||||
"player_inventory": [],
|
|
||||||
"entities": entities,
|
|
||||||
"tiles": _test_tiles(),
|
|
||||||
"visible_tiles": _test_visible_tiles(),
|
|
||||||
"visible_positions": _test_visible_positions(),
|
|
||||||
"nearby_interactions": nearby,
|
|
||||||
"current_monologue": monologue,
|
|
||||||
"current_dialogue": dialogue,
|
|
||||||
"pending_recognitions": pending_recs,
|
|
||||||
"gauntlet_mode": _test_gauntlet_mode,
|
|
||||||
"conversation_events": conv_events,
|
|
||||||
"conversation_ended": conv_ended,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
|
||||||
func _test_tiles() -> Array:
|
|
||||||
var tiles: Array = []
|
|
||||||
var room_x := 7
|
|
||||||
var room_y := 7
|
|
||||||
var room_w := 8
|
|
||||||
var room_h := 8
|
|
||||||
|
|
||||||
for x in range(room_x, room_x + room_w):
|
|
||||||
for y in range(room_y, room_y + room_h):
|
|
||||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
|
||||||
or y == room_y or y == room_y + room_h - 1)
|
|
||||||
var tile_type: String
|
|
||||||
if is_edge:
|
|
||||||
# Door on the south wall, center
|
|
||||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
|
||||||
tile_type = "door"
|
|
||||||
else:
|
|
||||||
tile_type = "wall"
|
|
||||||
else:
|
|
||||||
tile_type = "floor"
|
|
||||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
|
||||||
|
|
||||||
# Corridor south of the door
|
|
||||||
var door_x := room_x + room_w / 2
|
|
||||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
|
||||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
|
||||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
|
||||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
# Test visible tiles with visibility sectors (v2 format)
|
|
||||||
# Tiles ahead of the player are Forward, others Peripheral.
|
|
||||||
func _test_visible_tiles() -> Array:
|
|
||||||
var vtiles: Array = []
|
|
||||||
var px := _test_player_pos.x
|
|
||||||
var py := _test_player_pos.y
|
|
||||||
var radius := 4
|
|
||||||
var room_x := 7
|
|
||||||
var room_y := 7
|
|
||||||
var room_w := 8
|
|
||||||
var room_h := 8
|
|
||||||
|
|
||||||
for x in range(px - radius, px + radius + 1):
|
|
||||||
for y in range(py - radius, py + radius + 1):
|
|
||||||
var dist := absf(x - px) + absf(y - py)
|
|
||||||
if dist <= radius:
|
|
||||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
|
||||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
|
||||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
|
||||||
return vtiles
|
|
||||||
|
|
||||||
# Test visibility: tiles within radius 4 of player, inside room bounds
|
|
||||||
func _test_visible_positions() -> Array:
|
|
||||||
var positions: Array = []
|
|
||||||
var px := _test_player_pos.x
|
|
||||||
var py := _test_player_pos.y
|
|
||||||
var radius := 4
|
|
||||||
var room_x := 7
|
|
||||||
var room_y := 7
|
|
||||||
var room_w := 8
|
|
||||||
var room_h := 8
|
|
||||||
|
|
||||||
for x in range(px - radius, px + radius + 1):
|
|
||||||
for y in range(py - radius, py + radius + 1):
|
|
||||||
var dist := absf(x - px) + absf(y - py)
|
|
||||||
if dist <= radius:
|
|
||||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
|
||||||
positions.append({"x": x, "y": y})
|
|
||||||
return positions
|
|
||||||
|
|
||||||
|
|
||||||
# -- Test mode helpers --
|
|
||||||
|
|
||||||
const _TEST_WALLS: Array = [
|
|
||||||
# Room walls (8x8 room from (7,7) to (14,14))
|
|
||||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
|
||||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
|
||||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
|
||||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
|
||||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
|
||||||
Vector2i(7,12), Vector2i(7,13),
|
|
||||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
|
||||||
Vector2i(14,12), Vector2i(14,13),
|
|
||||||
# Interior wall blocking NPC
|
|
||||||
Vector2i(12, 10),
|
|
||||||
]
|
|
||||||
|
|
||||||
func _test_is_walkable(pos: Vector2i) -> bool:
|
|
||||||
return not _TEST_WALLS.has(pos)
|
|
||||||
|
|
||||||
# Simple LOS check — blocked if a wall tile sits between start and end
|
|
||||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
|
||||||
# Bresenham-lite: check tiles along the line
|
|
||||||
var dx := absi(to.x - from.x)
|
|
||||||
var dy := absi(to.y - from.y)
|
|
||||||
var sx := 1 if from.x < to.x else -1
|
|
||||||
var sy := 1 if from.y < to.y else -1
|
|
||||||
var err := dx - dy
|
|
||||||
var cx := from.x
|
|
||||||
var cy := from.y
|
|
||||||
while true:
|
|
||||||
if cx == to.x and cy == to.y:
|
|
||||||
return true
|
|
||||||
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
|
|
||||||
return false
|
|
||||||
var e2 := 2 * err
|
|
||||||
if e2 > -dy:
|
|
||||||
err -= dy
|
|
||||||
cx += sx
|
|
||||||
if e2 < dx:
|
|
||||||
err += dx
|
|
||||||
cy += sy
|
|
||||||
return true
|
|
||||||
|
|
||||||
static func _action_to_delta(action_name: String) -> Vector2i:
|
|
||||||
match action_name:
|
|
||||||
"MoveNorth": return Vector2i(0, -1)
|
|
||||||
"MoveNortheast": return Vector2i(1, -1)
|
|
||||||
"MoveEast": return Vector2i(1, 0)
|
|
||||||
"MoveSoutheast": return Vector2i(1, 1)
|
|
||||||
"MoveSouth": return Vector2i(0, 1)
|
|
||||||
"MoveSouthwest": return Vector2i(-1, 1)
|
|
||||||
"MoveWest": return Vector2i(-1, 0)
|
|
||||||
"MoveNorthwest": return Vector2i(-1, -1)
|
|
||||||
_: return Vector2i.ZERO
|
|
||||||
|
|
||||||
static func _delta_to_facing(delta: Vector2i) -> String:
|
|
||||||
match delta:
|
|
||||||
Vector2i(0, -1): return "North"
|
|
||||||
Vector2i(1, -1): return "Northeast"
|
|
||||||
Vector2i(1, 0): return "East"
|
|
||||||
Vector2i(1, 1): return "Southeast"
|
|
||||||
Vector2i(0, 1): return "South"
|
|
||||||
Vector2i(-1, 1): return "Southwest"
|
|
||||||
Vector2i(-1, 0): return "West"
|
|
||||||
Vector2i(-1, -1): return "Northwest"
|
|
||||||
_: return "North"
|
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ func _process(delta: float) -> void:
|
|||||||
_consume_conversation_ended()
|
_consume_conversation_ended()
|
||||||
_consume_dialogue_response()
|
_consume_dialogue_response()
|
||||||
|
|
||||||
|
# #554: Show save/load result notification
|
||||||
|
_consume_save_result()
|
||||||
|
|
||||||
# Track camera to player (D-015: locked, fixed-north).
|
# Track camera to player (D-015: locked, fixed-north).
|
||||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||||
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
||||||
@@ -381,6 +384,27 @@ func _consume_dialogue_response() -> void:
|
|||||||
GameState.dialogue_response = null
|
GameState.dialogue_response = null
|
||||||
|
|
||||||
|
|
||||||
|
# #554: Show save/load result notification from server response.
|
||||||
|
func _consume_save_result() -> void:
|
||||||
|
if GameState.save_result == null:
|
||||||
|
return
|
||||||
|
var result: Dictionary = GameState.save_result
|
||||||
|
GameState.save_result = null # consume once
|
||||||
|
var msg: String
|
||||||
|
if result.get("success", false):
|
||||||
|
if result.get("kind", "") == "save":
|
||||||
|
msg = UIStrings.get_text("notifications.save_complete")
|
||||||
|
else:
|
||||||
|
msg = UIStrings.get_text("notifications.load_complete")
|
||||||
|
else:
|
||||||
|
if result.get("kind", "") == "save":
|
||||||
|
msg = UIStrings.get_text("notifications.save_failed")
|
||||||
|
else:
|
||||||
|
msg = UIStrings.get_text("notifications.load_failed")
|
||||||
|
if monologue_display:
|
||||||
|
monologue_display.show_notification(msg)
|
||||||
|
|
||||||
|
|
||||||
# D-061: Handle dialogue option selection → send to server
|
# D-061: Handle dialogue option selection → send to server
|
||||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||||
SimBridge.send_input({
|
SimBridge.send_input({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class_name Protocol
|
|||||||
|
|
||||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||||
## Reject snapshots where version != this value.
|
## Reject snapshots where version != this value.
|
||||||
const PROTOCOL_VERSION: int = 14
|
const PROTOCOL_VERSION: int = 15
|
||||||
|
|
||||||
|
|
||||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||||
@@ -207,7 +207,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||||
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
|
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||||
var poi_list: Array = []
|
var poi_list: Array = []
|
||||||
var raw_pois: Variant = raw.get("poi_list")
|
var raw_pois: Variant = raw.get("poi_list")
|
||||||
if raw_pois is Array:
|
if raw_pois is Array:
|
||||||
@@ -219,7 +219,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"x": int(raw_poi["x"]),
|
"x": int(raw_poi["x"]),
|
||||||
"y": int(raw_poi["y"]),
|
"y": int(raw_poi["y"]),
|
||||||
"z": int(raw_poi.get("z", 0)),
|
"z": int(raw_poi.get("z", 0)),
|
||||||
"category": str(raw_poi.get("category", "Location")),
|
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||||
})
|
})
|
||||||
|
|
||||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||||
@@ -233,6 +233,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# v15: save_result (#554, D-085) — one-shot save/load operation result.
|
||||||
|
# {success: bool, kind: "save"|"load", error: String|null}
|
||||||
|
var save_result: Variant = null
|
||||||
|
var raw_save: Variant = raw.get("save_result")
|
||||||
|
if raw_save is Dictionary:
|
||||||
|
save_result = {
|
||||||
|
"success": bool(raw_save.get("success", false)),
|
||||||
|
"kind": str(raw_save.get("kind", "")),
|
||||||
|
"error": raw_save.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||||
@@ -290,6 +301,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"poi_list": poi_list,
|
"poi_list": poi_list,
|
||||||
"examine_result": examine_result,
|
"examine_result": examine_result,
|
||||||
"player_knowledge": player_knowledge,
|
"player_knowledge": player_knowledge,
|
||||||
|
"save_result": save_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
class_name TestHarness
|
||||||
|
extends RefCounted
|
||||||
|
## Standalone test simulation for client development without a running server.
|
||||||
|
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
|
||||||
|
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||||
|
## boundary (no game logic in the production client autoload).
|
||||||
|
|
||||||
|
var tick: int = 0
|
||||||
|
var player_pos: Vector2i = Vector2i(10, 10)
|
||||||
|
var facing: String = "North"
|
||||||
|
var input_queue: Array = []
|
||||||
|
var in_dialogue: bool = false
|
||||||
|
var gauntlet_mode: bool = false
|
||||||
|
var npc_relationship: String = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
func reset() -> void:
|
||||||
|
tick = 0
|
||||||
|
player_pos = Vector2i(10, 10)
|
||||||
|
facing = "North"
|
||||||
|
input_queue.clear()
|
||||||
|
in_dialogue = false
|
||||||
|
gauntlet_mode = false
|
||||||
|
npc_relationship = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
func process_input(action_name: String) -> void:
|
||||||
|
input_queue.append(action_name)
|
||||||
|
|
||||||
|
|
||||||
|
func process_facing(new_facing: String) -> void:
|
||||||
|
facing = new_facing
|
||||||
|
|
||||||
|
|
||||||
|
# -- Snapshot generation -------------------------------------------------------
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
tick += 1
|
||||||
|
|
||||||
|
# Process queued inputs
|
||||||
|
for action_name in input_queue:
|
||||||
|
if action_name == "TeleportToHub":
|
||||||
|
player_pos = Vector2i(10, 10)
|
||||||
|
in_dialogue = false
|
||||||
|
continue
|
||||||
|
if action_name == "Interact":
|
||||||
|
var npc_pos := Vector2i(12, 9)
|
||||||
|
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
|
||||||
|
if dist <= 2 and has_los(player_pos, npc_pos):
|
||||||
|
in_dialogue = true
|
||||||
|
continue
|
||||||
|
var delta := action_to_delta(action_name)
|
||||||
|
var new_pos := player_pos + delta
|
||||||
|
if _is_walkable(new_pos):
|
||||||
|
player_pos = new_pos
|
||||||
|
if delta != Vector2i.ZERO:
|
||||||
|
if in_dialogue:
|
||||||
|
in_dialogue = false
|
||||||
|
input_queue.clear()
|
||||||
|
|
||||||
|
var px := player_pos.x
|
||||||
|
var py := player_pos.y
|
||||||
|
|
||||||
|
# Build entities — player always visible
|
||||||
|
var entities: Array = [{
|
||||||
|
"entity_id": 1,
|
||||||
|
"x": float(px),
|
||||||
|
"y": float(py),
|
||||||
|
"z": 0,
|
||||||
|
"kind": { "variant": "Player", "data": null },
|
||||||
|
"visibility": "Forward",
|
||||||
|
}]
|
||||||
|
|
||||||
|
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||||
|
var npc_pos := Vector2i(12, 9)
|
||||||
|
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||||
|
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||||
|
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||||
|
entities.append({
|
||||||
|
"entity_id": 2,
|
||||||
|
"x": float(npc_pos.x),
|
||||||
|
"y": float(npc_pos.y),
|
||||||
|
"z": 0,
|
||||||
|
"kind": { "variant": "Npc", "data": null },
|
||||||
|
"visibility": sector,
|
||||||
|
"relationship": npc_relationship,
|
||||||
|
})
|
||||||
|
|
||||||
|
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||||
|
var nearby: Array = []
|
||||||
|
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||||
|
nearby.append({
|
||||||
|
"entity_id": 2,
|
||||||
|
"entity_type": "Npc",
|
||||||
|
"distance": npc_dist,
|
||||||
|
"verbs": [
|
||||||
|
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||||
|
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
# v5: monologue on first tick (#414)
|
||||||
|
var monologue: Variant = null
|
||||||
|
if tick == 1:
|
||||||
|
monologue = {
|
||||||
|
"id": "test_enter_001",
|
||||||
|
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||||
|
"duration_seconds": 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# v7: mock dialogue (#435, D-061/D-062)
|
||||||
|
var dialogue: Variant = null
|
||||||
|
if in_dialogue:
|
||||||
|
dialogue = {
|
||||||
|
"npc_name": "Kael",
|
||||||
|
"npc_entity_id": 2,
|
||||||
|
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||||
|
"options": [
|
||||||
|
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||||
|
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||||
|
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# v7: mock pending_recognitions (#431, D-059/D-060)
|
||||||
|
var pending_recs: Array = []
|
||||||
|
var cycle_pos := tick % 12
|
||||||
|
if cycle_pos < 6:
|
||||||
|
var total_delay := 6
|
||||||
|
var remaining := total_delay - cycle_pos
|
||||||
|
pending_recs.append({
|
||||||
|
"entity_id": 100,
|
||||||
|
"x": 13.5,
|
||||||
|
"y": 12.5,
|
||||||
|
"z": 0,
|
||||||
|
"remaining_ticks": remaining,
|
||||||
|
"total_delay_ticks": total_delay,
|
||||||
|
})
|
||||||
|
|
||||||
|
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||||
|
var conv_events: Array = []
|
||||||
|
var conv_ended: Array = []
|
||||||
|
var conv_start := 3
|
||||||
|
var conv_lines := [
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||||
|
]
|
||||||
|
var conv_tick_interval := 5
|
||||||
|
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||||
|
if tick >= conv_start and tick < conv_start + conv_total_ticks:
|
||||||
|
var conv_index := (tick - conv_start) / conv_tick_interval
|
||||||
|
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||||
|
if within_tick == 0 and conv_index < conv_lines.size():
|
||||||
|
var cl: Dictionary = conv_lines[conv_index]
|
||||||
|
conv_events.append({
|
||||||
|
"speaker_id": 10,
|
||||||
|
"target_id": 11,
|
||||||
|
"speaker_name": cl.speaker,
|
||||||
|
"target_name": cl.target,
|
||||||
|
"occluded_line": cl.line,
|
||||||
|
})
|
||||||
|
elif tick == conv_start + conv_total_ticks:
|
||||||
|
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tick": tick,
|
||||||
|
"version": Protocol.PROTOCOL_VERSION,
|
||||||
|
"game_time": {
|
||||||
|
"day": 0,
|
||||||
|
"time_of_day": tick * 10,
|
||||||
|
"day_phase": "Morning",
|
||||||
|
"tick_rate": "Full",
|
||||||
|
},
|
||||||
|
"player_facing": facing,
|
||||||
|
"player_stance": "Walk",
|
||||||
|
"player_inventory": [],
|
||||||
|
"entities": entities,
|
||||||
|
"tiles": _tiles(),
|
||||||
|
"visible_tiles": _visible_tiles(),
|
||||||
|
"visible_positions": _visible_positions(),
|
||||||
|
"nearby_interactions": nearby,
|
||||||
|
"current_monologue": monologue,
|
||||||
|
"current_dialogue": dialogue,
|
||||||
|
"pending_recognitions": pending_recs,
|
||||||
|
"gauntlet_mode": gauntlet_mode,
|
||||||
|
"conversation_events": conv_events,
|
||||||
|
"conversation_ended": conv_ended,
|
||||||
|
"save_result": null,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Map generation ------------------------------------------------------------
|
||||||
|
|
||||||
|
func _tiles() -> Array:
|
||||||
|
var tiles: Array = []
|
||||||
|
var room_x := 7
|
||||||
|
var room_y := 7
|
||||||
|
var room_w := 8
|
||||||
|
var room_h := 8
|
||||||
|
|
||||||
|
for x in range(room_x, room_x + room_w):
|
||||||
|
for y in range(room_y, room_y + room_h):
|
||||||
|
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||||
|
or y == room_y or y == room_y + room_h - 1)
|
||||||
|
var tile_type: String
|
||||||
|
if is_edge:
|
||||||
|
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||||
|
tile_type = "door"
|
||||||
|
else:
|
||||||
|
tile_type = "wall"
|
||||||
|
else:
|
||||||
|
tile_type = "floor"
|
||||||
|
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||||
|
|
||||||
|
var door_x := room_x + room_w / 2
|
||||||
|
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||||
|
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||||
|
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||||
|
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||||
|
|
||||||
|
return tiles
|
||||||
|
|
||||||
|
|
||||||
|
func _visible_tiles() -> Array:
|
||||||
|
var vtiles: Array = []
|
||||||
|
var px := player_pos.x
|
||||||
|
var py := player_pos.y
|
||||||
|
var radius := 4
|
||||||
|
var room_x := 7
|
||||||
|
var room_y := 7
|
||||||
|
var room_w := 8
|
||||||
|
var room_h := 8
|
||||||
|
|
||||||
|
for x in range(px - radius, px + radius + 1):
|
||||||
|
for y in range(py - radius, py + radius + 1):
|
||||||
|
var dist := absf(x - px) + absf(y - py)
|
||||||
|
if dist <= radius:
|
||||||
|
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||||
|
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||||
|
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||||
|
return vtiles
|
||||||
|
|
||||||
|
|
||||||
|
func _visible_positions() -> Array:
|
||||||
|
var positions: Array = []
|
||||||
|
var px := player_pos.x
|
||||||
|
var py := player_pos.y
|
||||||
|
var radius := 4
|
||||||
|
var room_x := 7
|
||||||
|
var room_y := 7
|
||||||
|
var room_w := 8
|
||||||
|
var room_h := 8
|
||||||
|
|
||||||
|
for x in range(px - radius, px + radius + 1):
|
||||||
|
for y in range(py - radius, py + radius + 1):
|
||||||
|
var dist := absf(x - px) + absf(y - py)
|
||||||
|
if dist <= radius:
|
||||||
|
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||||
|
positions.append({"x": x, "y": y})
|
||||||
|
return positions
|
||||||
|
|
||||||
|
|
||||||
|
# -- Spatial helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
const _WALLS: Array = [
|
||||||
|
# Room walls (8x8 room from (7,7) to (14,14))
|
||||||
|
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||||
|
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||||
|
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||||
|
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||||
|
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||||
|
Vector2i(7,12), Vector2i(7,13),
|
||||||
|
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||||
|
Vector2i(14,12), Vector2i(14,13),
|
||||||
|
# Interior wall blocking NPC
|
||||||
|
Vector2i(12, 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func _is_walkable(pos: Vector2i) -> bool:
|
||||||
|
return not _WALLS.has(pos)
|
||||||
|
|
||||||
|
|
||||||
|
func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||||
|
var dx := absi(to.x - from.x)
|
||||||
|
var dy := absi(to.y - from.y)
|
||||||
|
var sx := 1 if from.x < to.x else -1
|
||||||
|
var sy := 1 if from.y < to.y else -1
|
||||||
|
var err := dx - dy
|
||||||
|
var cx := from.x
|
||||||
|
var cy := from.y
|
||||||
|
while true:
|
||||||
|
if cx == to.x and cy == to.y:
|
||||||
|
return true
|
||||||
|
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
|
||||||
|
return false
|
||||||
|
var e2 := 2 * err
|
||||||
|
if e2 > -dy:
|
||||||
|
err -= dy
|
||||||
|
cx += sx
|
||||||
|
if e2 < dx:
|
||||||
|
err += dx
|
||||||
|
cy += sy
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func action_to_delta(action_name: String) -> Vector2i:
|
||||||
|
match action_name:
|
||||||
|
"MoveNorth": return Vector2i(0, -1)
|
||||||
|
"MoveNortheast": return Vector2i(1, -1)
|
||||||
|
"MoveEast": return Vector2i(1, 0)
|
||||||
|
"MoveSoutheast": return Vector2i(1, 1)
|
||||||
|
"MoveSouth": return Vector2i(0, 1)
|
||||||
|
"MoveSouthwest": return Vector2i(-1, 1)
|
||||||
|
"MoveWest": return Vector2i(-1, 0)
|
||||||
|
"MoveNorthwest": return Vector2i(-1, -1)
|
||||||
|
_: return Vector2i.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
static func delta_to_facing(delta: Vector2i) -> String:
|
||||||
|
match delta:
|
||||||
|
Vector2i(0, -1): return "North"
|
||||||
|
Vector2i(1, -1): return "Northeast"
|
||||||
|
Vector2i(1, 0): return "East"
|
||||||
|
Vector2i(1, 1): return "Southeast"
|
||||||
|
Vector2i(0, 1): return "South"
|
||||||
|
Vector2i(-1, 1): return "Southwest"
|
||||||
|
Vector2i(-1, 0): return "West"
|
||||||
|
Vector2i(-1, -1): return "Northwest"
|
||||||
|
_: return "North"
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
extends Control
|
extends Control
|
||||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
## #348: F3 debug overlay — real-time visualization of game state for dev use.
|
||||||
|
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
|
||||||
|
##
|
||||||
|
## Panels:
|
||||||
|
## 1. Stats text (top-left): tick, pos, fps, etc.
|
||||||
|
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
|
||||||
|
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const HEADER_COLOR := Color("#e8c547")
|
const HEADER_COLOR := Color("#e8c547")
|
||||||
const LABEL_COLOR := Color("#8890a0")
|
const LABEL_COLOR := Color("#8890a0")
|
||||||
@@ -8,31 +18,150 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
|||||||
const FONT_SIZE := 12
|
const FONT_SIZE := 12
|
||||||
const LINE_HEIGHT := 16
|
const LINE_HEIGHT := 16
|
||||||
const PADDING := Vector2(10, 8)
|
const PADDING := Vector2(10, 8)
|
||||||
const COL_GAP := 16 # gap between left and right columns
|
const COL_GAP := 16
|
||||||
|
|
||||||
|
# World overlay colors
|
||||||
|
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
|
||||||
|
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
|
||||||
|
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
|
||||||
|
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
|
||||||
|
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
|
||||||
|
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
|
||||||
|
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
|
||||||
|
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
|
||||||
|
const TAG_TEXT_COLOR := Color("#c8d0e0")
|
||||||
|
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
|
||||||
|
const GRAPH_LINE_COLOR := Color("#6bc9a6")
|
||||||
|
const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||||
|
|
||||||
|
# Vision cone geometry (radians)
|
||||||
|
# Forward: ±60° around facing direction (120° total)
|
||||||
|
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||||
|
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||||
|
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||||
|
const CONE_ARC_STEPS: int = 20
|
||||||
|
|
||||||
|
# NPC path history
|
||||||
|
const NPC_HISTORY_LEN: int = 12
|
||||||
|
const NPC_DOT_RADIUS: float = 3.5
|
||||||
|
const PLAYER_DOT_RADIUS: float = 5.0
|
||||||
|
|
||||||
|
# Tick timing graph
|
||||||
|
const GRAPH_W: float = 160.0
|
||||||
|
const GRAPH_H: float = 48.0
|
||||||
|
const GRAPH_MARGIN: float = 10.0
|
||||||
|
const TICK_HISTORY_LEN: int = 30
|
||||||
|
const TICK_WARN_MS: float = 120.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var _cached_font: Font = null
|
var _cached_font: Font = null
|
||||||
|
var _dev_mode: bool = false
|
||||||
|
|
||||||
|
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
|
||||||
|
var _npc_paths: Dictionary = {}
|
||||||
|
var _last_tick_processed: int = -1
|
||||||
|
|
||||||
|
# Tick timing ring
|
||||||
|
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||||
|
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_dev_mode = OS.is_debug_build()
|
||||||
visible = false
|
visible = false
|
||||||
_cached_font = ThemeDB.fallback_font
|
_cached_font = ThemeDB.fallback_font
|
||||||
|
# Clear NPC path history on session change to prevent entity ID collisions
|
||||||
|
GameState.connect("game_id_changed", _on_game_id_changed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_game_id_changed(_new_id: String) -> void:
|
||||||
|
_npc_paths.clear()
|
||||||
|
_tick_deltas.clear()
|
||||||
|
_tick_times.clear()
|
||||||
|
_last_tick_processed = -1
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not _dev_mode:
|
||||||
|
return
|
||||||
if event.is_action_pressed("debug_overlay"):
|
if event.is_action_pressed("debug_overlay"):
|
||||||
visible = not visible
|
visible = not visible
|
||||||
if visible:
|
if visible:
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func update_from_state() -> void:
|
func update_from_state() -> void:
|
||||||
if not visible:
|
if not _dev_mode or not visible:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Record tick arrival time for timing graph
|
||||||
|
var now_ms := Time.get_ticks_msec()
|
||||||
|
if _last_tick_processed != GameState.current_tick:
|
||||||
|
_last_tick_processed = GameState.current_tick
|
||||||
|
if _tick_times.size() > 0:
|
||||||
|
_tick_deltas.append(float(now_ms - _tick_times.back()))
|
||||||
|
if _tick_deltas.size() > TICK_HISTORY_LEN:
|
||||||
|
_tick_deltas.pop_front()
|
||||||
|
_tick_times.append(now_ms)
|
||||||
|
if _tick_times.size() > TICK_HISTORY_LEN + 1:
|
||||||
|
_tick_times.pop_front()
|
||||||
|
_update_npc_paths()
|
||||||
|
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_npc_paths() -> void:
|
||||||
|
var seen_ids: Dictionary = {}
|
||||||
|
for entity in GameState.visible_entities:
|
||||||
|
if not entity is Dictionary:
|
||||||
|
continue
|
||||||
|
var kind_variant: String = entity.get("kind", {}).get("variant", "")
|
||||||
|
if kind_variant != "Npc":
|
||||||
|
continue
|
||||||
|
var eid: int = entity.get("entity_id", -1)
|
||||||
|
if eid < 0:
|
||||||
|
continue
|
||||||
|
seen_ids[eid] = true
|
||||||
|
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||||
|
if not _npc_paths.has(eid):
|
||||||
|
_npc_paths[eid] = []
|
||||||
|
var path: Array = _npc_paths[eid]
|
||||||
|
if path.size() == 0 or path.back() != pos:
|
||||||
|
path.append(pos)
|
||||||
|
if path.size() > NPC_HISTORY_LEN:
|
||||||
|
path.pop_front()
|
||||||
|
# Prune entities no longer visible
|
||||||
|
for eid in _npc_paths.keys():
|
||||||
|
if not seen_ids.has(eid):
|
||||||
|
_npc_paths.erase(eid)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Draw dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
if not visible:
|
if not visible:
|
||||||
return
|
return
|
||||||
|
_draw_stats_panel()
|
||||||
|
_draw_world_overlays()
|
||||||
|
_draw_tick_graph()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Panel 1: Stats text (top-left)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _draw_stats_panel() -> void:
|
||||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||||
|
|
||||||
# Build lines as [label, value, label, value] pairs (two columns)
|
|
||||||
var left_lines: Array = []
|
var left_lines: Array = []
|
||||||
var right_lines: Array = []
|
var right_lines: Array = []
|
||||||
|
|
||||||
@@ -71,7 +200,10 @@ func _draw() -> void:
|
|||||||
left_lines.append(["mode", mode_str])
|
left_lines.append(["mode", mode_str])
|
||||||
right_lines.append(["gauntlet", gauntlet_str])
|
right_lines.append(["gauntlet", gauntlet_str])
|
||||||
|
|
||||||
# Measure column widths
|
var gid := GameState.current_game_id
|
||||||
|
left_lines.append(["game_id", gid if gid != "" else "-"])
|
||||||
|
right_lines.append(["npc_paths", str(_npc_paths.size())])
|
||||||
|
|
||||||
var left_label_w: float = 0.0
|
var left_label_w: float = 0.0
|
||||||
var left_value_w: float = 0.0
|
var left_value_w: float = 0.0
|
||||||
var right_label_w: float = 0.0
|
var right_label_w: float = 0.0
|
||||||
@@ -89,27 +221,240 @@ func _draw() -> void:
|
|||||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
|
||||||
|
|
||||||
# Background
|
|
||||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||||
|
|
||||||
# Header
|
|
||||||
var y: float = PADDING.y + FONT_SIZE
|
var y: float = PADDING.y + FONT_SIZE
|
||||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||||
y += LINE_HEIGHT
|
y += LINE_HEIGHT
|
||||||
|
|
||||||
# Data lines (two columns)
|
|
||||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||||
for i in range(line_count):
|
for i in range(line_count):
|
||||||
if i < left_lines.size():
|
if i < left_lines.size():
|
||||||
var lbl: String = left_lines[i][0] + ": "
|
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||||
var val: String = left_lines[i][1]
|
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
|
||||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
|
||||||
if i < right_lines.size():
|
if i < right_lines.size():
|
||||||
var lbl: String = right_lines[i][0] + ": "
|
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||||
var val: String = right_lines[i][1]
|
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
|
||||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
|
||||||
y += LINE_HEIGHT
|
y += LINE_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Panel 2: World overlays
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _draw_world_overlays() -> void:
|
||||||
|
var vp := get_viewport()
|
||||||
|
if vp == null:
|
||||||
|
return
|
||||||
|
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
|
||||||
|
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
|
||||||
|
# Using this transform converts world coords → screen pixel coords for the overlays.
|
||||||
|
var canvas_xf := vp.get_canvas_transform()
|
||||||
|
var player_screen := _w2s(GameState.player_position, canvas_xf)
|
||||||
|
|
||||||
|
_draw_vision_cone(player_screen, canvas_xf)
|
||||||
|
_draw_los_rays(player_screen, canvas_xf)
|
||||||
|
_draw_npc_paths(canvas_xf)
|
||||||
|
_draw_info_tags(canvas_xf)
|
||||||
|
|
||||||
|
|
||||||
|
# Convert world tile position → screen pixel position
|
||||||
|
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
|
||||||
|
return canvas_xf * (world_pos * Constants.TILE_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
# Vision cone: filled forward sector + peripheral bands.
|
||||||
|
# Uses player facing direction and visibility sector distance.
|
||||||
|
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||||
|
var facing_angle := _facing_to_angle(GameState.player_facing)
|
||||||
|
|
||||||
|
# Estimate visible radius from furthest visibility sector tile
|
||||||
|
var max_d: float = 4.0
|
||||||
|
for vpos in GameState.visibility_sectors.keys():
|
||||||
|
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
|
||||||
|
if d > max_d:
|
||||||
|
max_d = d
|
||||||
|
var scale_x := canvas_xf.x.length()
|
||||||
|
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
|
||||||
|
|
||||||
|
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
|
||||||
|
var forward_from := facing_angle - CONE_FORWARD_HALF
|
||||||
|
var forward_to := facing_angle + CONE_FORWARD_HALF
|
||||||
|
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
|
||||||
|
var perip_l_to := facing_angle - CONE_FORWARD_HALF
|
||||||
|
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||||
|
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||||
|
|
||||||
|
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||||
|
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||||
|
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||||
|
|
||||||
|
# Forward arc boundary ring
|
||||||
|
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||||
|
|
||||||
|
# Player dot
|
||||||
|
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
|
||||||
|
|
||||||
|
|
||||||
|
# Build a filled polygon fan from center through an arc
|
||||||
|
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
pts.append(center)
|
||||||
|
for i in range(CONE_ARC_STEPS + 1):
|
||||||
|
var t := float(i) / float(CONE_ARC_STEPS)
|
||||||
|
var a := angle_from + t * (angle_to - angle_from)
|
||||||
|
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
# Dashed LOS lines from player to each visible non-player entity
|
||||||
|
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||||
|
for entity in GameState.visible_entities:
|
||||||
|
if not entity is Dictionary:
|
||||||
|
continue
|
||||||
|
if entity.get("kind", {}).get("variant", "") == "Player":
|
||||||
|
continue
|
||||||
|
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||||
|
var entity_screen := _w2s(entity_world, canvas_xf)
|
||||||
|
var rel: String = entity.get("relationship", "Unknown")
|
||||||
|
var color := Constants.color_for_relationship(rel)
|
||||||
|
color.a = 0.45
|
||||||
|
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
|
||||||
|
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
|
||||||
|
|
||||||
|
|
||||||
|
# Fading NPC movement path trails from position history
|
||||||
|
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||||
|
for eid in _npc_paths.keys():
|
||||||
|
var path: Array = _npc_paths[eid]
|
||||||
|
if path.size() < 2:
|
||||||
|
continue
|
||||||
|
for i in range(1, path.size()):
|
||||||
|
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||||
|
var b_screen := _w2s(path[i], canvas_xf)
|
||||||
|
var alpha := float(i) / float(path.size())
|
||||||
|
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||||
|
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||||
|
|
||||||
|
|
||||||
|
# Information state tags above visible NPCs from player_knowledge
|
||||||
|
func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||||
|
if GameState.player_knowledge == null:
|
||||||
|
return
|
||||||
|
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||||
|
var knowledge: Dictionary = GameState.player_knowledge
|
||||||
|
|
||||||
|
# Build entity_id → knowledge entry lookup
|
||||||
|
var kg_by_id: Dictionary = {}
|
||||||
|
for entry in knowledge.get("entities", []):
|
||||||
|
if entry is Dictionary and entry.has("entity_id"):
|
||||||
|
kg_by_id[entry.entity_id] = entry
|
||||||
|
|
||||||
|
for entity in GameState.visible_entities:
|
||||||
|
if not entity is Dictionary:
|
||||||
|
continue
|
||||||
|
if entity.get("kind", {}).get("variant", "") != "Npc":
|
||||||
|
continue
|
||||||
|
var eid: int = entity.get("entity_id", -1)
|
||||||
|
if not kg_by_id.has(eid):
|
||||||
|
continue
|
||||||
|
var kg_entry: Dictionary = kg_by_id[eid]
|
||||||
|
var confidence: String = kg_entry.get("confidence", "Unknown")
|
||||||
|
var name_str: String = kg_entry.get("name", "?")
|
||||||
|
var label := "%s [%s]" % [name_str, confidence]
|
||||||
|
|
||||||
|
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
|
||||||
|
var tag_baseline := entity_screen.y - 18.0
|
||||||
|
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
|
||||||
|
var tag_rect := Rect2(
|
||||||
|
entity_screen.x - text_w / 2.0 - 3.0,
|
||||||
|
tag_baseline - FONT_SIZE + 2.0,
|
||||||
|
text_w + 6.0,
|
||||||
|
FONT_SIZE)
|
||||||
|
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||||
|
draw_string(font,
|
||||||
|
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||||
|
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Panel 3: Tick timing sparkline (bottom-left)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _draw_tick_graph() -> void:
|
||||||
|
if _tick_deltas.size() < 2:
|
||||||
|
return
|
||||||
|
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||||
|
var vp_size := get_viewport_rect().size
|
||||||
|
var box_x := GRAPH_MARGIN
|
||||||
|
var label_h := LINE_HEIGHT
|
||||||
|
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||||
|
|
||||||
|
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||||
|
draw_string(font,
|
||||||
|
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||||
|
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||||
|
|
||||||
|
var chart_top := box_y + label_h
|
||||||
|
var chart_left := box_x + 4.0
|
||||||
|
var chart_w := GRAPH_W - 8.0
|
||||||
|
var chart_h := GRAPH_H - 4.0
|
||||||
|
|
||||||
|
# Max value for scale
|
||||||
|
var max_ms: float = TICK_WARN_MS
|
||||||
|
for d in _tick_deltas:
|
||||||
|
if float(d) > max_ms:
|
||||||
|
max_ms = float(d)
|
||||||
|
max_ms *= 1.1
|
||||||
|
|
||||||
|
# Warn threshold dashed line
|
||||||
|
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||||
|
draw_dashed_line(
|
||||||
|
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||||
|
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||||
|
1.0, 4.0)
|
||||||
|
|
||||||
|
# Sparkline
|
||||||
|
var n := _tick_deltas.size()
|
||||||
|
var prev_pt := Vector2.ZERO
|
||||||
|
for i in range(n):
|
||||||
|
var x := chart_left + chart_w * (float(i) / float(n - 1))
|
||||||
|
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
|
||||||
|
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
|
||||||
|
var pt := Vector2(x, y)
|
||||||
|
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
|
||||||
|
if i > 0:
|
||||||
|
draw_line(prev_pt, pt, color, 1.5)
|
||||||
|
draw_circle(pt, 2.0, color)
|
||||||
|
prev_pt = pt
|
||||||
|
|
||||||
|
# Average label
|
||||||
|
var avg_ms := 0.0
|
||||||
|
for d in _tick_deltas:
|
||||||
|
avg_ms += float(d)
|
||||||
|
avg_ms /= float(_tick_deltas.size())
|
||||||
|
draw_string(font,
|
||||||
|
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||||
|
"avg %.0fms" % avg_ms,
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||||
|
static func _facing_to_angle(facing: String) -> float:
|
||||||
|
match facing:
|
||||||
|
"North": return -PI / 2.0
|
||||||
|
"Northeast": return -PI / 4.0
|
||||||
|
"East": return 0.0
|
||||||
|
"Southeast": return PI / 4.0
|
||||||
|
"South": return PI / 2.0
|
||||||
|
"Southwest": return PI * 3.0 / 4.0
|
||||||
|
"West": return PI
|
||||||
|
"Northwest": return -PI * 3.0 / 4.0
|
||||||
|
_: return -PI / 2.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
��
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
うtickヲactionゥMoveNorth
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env -S godot -s
|
||||||
|
## gdUnit4 CI runner for the Settled Reach client.
|
||||||
|
##
|
||||||
|
## Run all client tests headlessly:
|
||||||
|
## godot --headless --path client/ \
|
||||||
|
## -s res://tests/run_gdunit4.gd \
|
||||||
|
## -- --ignoreHeadlessMode -a res://tests/
|
||||||
|
##
|
||||||
|
## Run a specific test file:
|
||||||
|
## godot --headless --path client/ \
|
||||||
|
## -s res://tests/run_gdunit4.gd \
|
||||||
|
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
|
||||||
|
##
|
||||||
|
## Exit code: 0 = all pass, non-zero = failures.
|
||||||
|
##
|
||||||
|
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
|
||||||
|
## Test output format: JSON summary per D-030 sub-decision #6.
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var _cli_runner: GdUnitTestCIRunner
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
|
||||||
|
_cli_runner = GdUnitTestCIRunner.new()
|
||||||
|
root.add_child(_cli_runner)
|
||||||
|
|
||||||
|
|
||||||
|
func _finalize() -> void:
|
||||||
|
queue_delete(_cli_runner)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
## Sprint 19 — Debug visualization overlay (#348)
|
||||||
|
## F3 toggle, world overlays, tick timing graph.
|
||||||
|
## Extends the existing debug_overlay.gd stub.
|
||||||
|
class_name TestDebugOverlaySprint19
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||||
|
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func _make_overlay() -> Control:
|
||||||
|
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||||
|
## Does not require the full main.tscn scene tree.
|
||||||
|
var script := load(DEBUG_SCRIPT_PATH)
|
||||||
|
if script == null:
|
||||||
|
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||||
|
return null
|
||||||
|
var node := Control.new()
|
||||||
|
node.set_script(script)
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.player_position = Vector2(10.0, 10.0)
|
||||||
|
GameState.player_facing = "North"
|
||||||
|
GameState.player_stance = "Walk"
|
||||||
|
GameState.current_tick = 1
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Script existence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_debug_overlay_script_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||||
|
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Instantiation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_that(ol).is_not_null()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_debug_overlay_starts_hidden() -> void:
|
||||||
|
## Overlay starts hidden — only appears when F3 pressed.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.visible).override_failure_message(
|
||||||
|
"DebugOverlay must start hidden (visible=false)"
|
||||||
|
).is_false()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dev-only guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_update_from_state_exists() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||||
|
"DebugOverlay must have update_from_state() method"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||||
|
## update_from_state() called while hidden must not crash.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = false
|
||||||
|
ol.update_from_state() # Should be a no-op, no crash
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
# Simulate a minimal snapshot tick
|
||||||
|
GameState.current_tick = 42
|
||||||
|
ol.update_from_state()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NPC path tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_npc_paths_field_exists() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||||
|
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_npc_paths_updated_on_state_update() -> void:
|
||||||
|
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 2,
|
||||||
|
"x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"relationship": "Unknown",
|
||||||
|
}]
|
||||||
|
GameState.current_tick = 100
|
||||||
|
ol.update_from_state()
|
||||||
|
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||||
|
"_npc_paths must record NPC positions from visible_entities"
|
||||||
|
).is_greater(0)
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||||
|
## Player entities must not appear in NPC path history.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 1,
|
||||||
|
"x": 10.0, "y": 10.0, "z": 0,
|
||||||
|
"kind": {"variant": "Player", "data": null},
|
||||||
|
}]
|
||||||
|
GameState.current_tick = 101
|
||||||
|
ol.update_from_state()
|
||||||
|
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||||
|
"Player entity must not appear in _npc_paths"
|
||||||
|
).is_equal(0)
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_npc_paths_max_length_respected() -> void:
|
||||||
|
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||||
|
for i in range(20):
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 5,
|
||||||
|
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"relationship": "Unknown",
|
||||||
|
}]
|
||||||
|
GameState.current_tick = 200 + i
|
||||||
|
ol.update_from_state()
|
||||||
|
var path: Array = ol._npc_paths.get(5, [])
|
||||||
|
assert_int(path.size()).override_failure_message(
|
||||||
|
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||||
|
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tick timing ring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_tick_deltas_field_exists() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||||
|
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||||
|
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
for i in range(5):
|
||||||
|
GameState.current_tick = 300 + i
|
||||||
|
ol.update_from_state()
|
||||||
|
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||||
|
"_tick_deltas must accumulate entries from successive ticks"
|
||||||
|
).is_greater(0)
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_tick_deltas_max_length_respected() -> void:
|
||||||
|
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
ol.visible = true
|
||||||
|
for i in range(50):
|
||||||
|
GameState.current_tick = 400 + i
|
||||||
|
ol.update_from_state()
|
||||||
|
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||||
|
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||||
|
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants defined
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_npc_history_len_constant_exists() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||||
|
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_tick_history_len_constant_exists() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||||
|
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_tick_warn_ms_constant_defined() -> void:
|
||||||
|
var ol := _make_overlay()
|
||||||
|
if ol == null: return
|
||||||
|
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||||
|
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||||
|
).is_true()
|
||||||
|
ol.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Facing angle helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_facing_to_angle_north() -> void:
|
||||||
|
## North = -PI/2 in Godot 2D (up on screen)
|
||||||
|
var angle := _fetch_facing_angle("North")
|
||||||
|
assert_float(angle).override_failure_message(
|
||||||
|
"_facing_to_angle('North') must return -PI/2"
|
||||||
|
).is_equal_approx(-PI / 2.0, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
func test_facing_to_angle_east() -> void:
|
||||||
|
var angle := _fetch_facing_angle("East")
|
||||||
|
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
func test_facing_to_angle_south() -> void:
|
||||||
|
var angle := _fetch_facing_angle("South")
|
||||||
|
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
func test_facing_to_angle_west() -> void:
|
||||||
|
var angle := _fetch_facing_angle("West")
|
||||||
|
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
func _fetch_facing_angle(facing: String) -> float:
|
||||||
|
## Helper: load script and call static method.
|
||||||
|
var script = load(DEBUG_SCRIPT_PATH)
|
||||||
|
if script == null:
|
||||||
|
return 0.0
|
||||||
|
# In GDScript 4, static methods can be called via an instance
|
||||||
|
var tmp := Control.new()
|
||||||
|
tmp.set_script(script)
|
||||||
|
add_child(tmp)
|
||||||
|
var result := tmp._facing_to_angle(facing)
|
||||||
|
tmp.queue_free()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-scene placement: DebugOverlay on UILayer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||||
|
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||||
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||||
|
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
var ui_layer := scene.get_node_or_null("UILayer")
|
||||||
|
assert_that(ui_layer != null).override_failure_message(
|
||||||
|
"UILayer must exist in main.tscn"
|
||||||
|
).is_true()
|
||||||
|
if ui_layer == null: return
|
||||||
|
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||||
|
assert_that(overlay != null).override_failure_message(
|
||||||
|
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||||
|
).is_true()
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
## Sprint 18 — Examine result display (#174)
|
||||||
|
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
|
||||||
|
##
|
||||||
|
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
|
||||||
|
## Positioned in InsertOverlay (CanvasLayer 10).
|
||||||
|
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
|
||||||
|
##
|
||||||
|
## Tests run against live Stig implementation (examine_display.gd).
|
||||||
|
class_name TestExamineDisplaySprint18
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
|
||||||
|
|
||||||
|
func _make_examine_display() -> Control:
|
||||||
|
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
|
||||||
|
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
|
||||||
|
return null
|
||||||
|
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func _make_result(overrides: Dictionary = {}) -> Dictionary:
|
||||||
|
var base: Dictionary = {
|
||||||
|
"entity_id": 42,
|
||||||
|
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||||
|
"confidence": "KnowsOf",
|
||||||
|
}
|
||||||
|
base.merge(overrides, true)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState: current_examine_result parsing
|
||||||
|
## (Now tests real implementation — not test-first stubs)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_field_exists() -> void:
|
||||||
|
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||||
|
"GameState must have 'current_examine_result' field (#174)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_by_default() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
|
||||||
|
assert_that(GameState.current_examine_result).is_not_null()
|
||||||
|
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||||
|
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
|
||||||
|
## The overlay must auto-dismiss — the server never re-sends the same result.
|
||||||
|
GameState.current_examine_result = _make_result()
|
||||||
|
GameState.apply_snapshot({"tick": 6})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
|
||||||
|
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
|
||||||
|
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
|
||||||
|
## Two examine results in sequence — second replaces first.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
|
||||||
|
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
|
||||||
|
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay scene
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_scene_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
|
||||||
|
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_instantiates_without_crash() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_that(display).is_not_null()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_show_result_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("show_result")).override_failure_message(
|
||||||
|
"ExamineDisplay must have show_result(result: Dictionary) method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_dismiss_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("dismiss")).override_failure_message(
|
||||||
|
"ExamineDisplay must have dismiss() method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_is_active_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("is_active")).override_failure_message(
|
||||||
|
"ExamineDisplay must have is_active() method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_not_active_on_init() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"ExamineDisplay must start inactive (no result showing)"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_not_visible_on_init() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.visible).override_failure_message(
|
||||||
|
"ExamineDisplay must start invisible"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_active_after_show_result() -> void:
|
||||||
|
## show_result() with valid text sets is_active() = true.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() with text must set is_active() = true"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_visible_after_show_result() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.visible).override_failure_message(
|
||||||
|
"show_result() must set visible = true"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_empty_text_ignored() -> void:
|
||||||
|
## show_result() with empty text must not activate (D-041: no empty observations).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() with empty text must not activate the display"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_inactive_after_dismiss() -> void:
|
||||||
|
## dismiss() immediately starts fade-out and sets _active = false.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.is_active()).is_true()
|
||||||
|
display.dismiss()
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"dismiss() must set is_active() = false immediately"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
|
||||||
|
## dismiss() on an inactive display must be safe (no crash, no state corruption).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.dismiss() # called when not active
|
||||||
|
assert_bool(display.is_active()).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_show_replaces_previous() -> void:
|
||||||
|
## Second show_result() replaces first (only one result at a time).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result({"text": "First observation."}))
|
||||||
|
display.show_result(_make_result({"text": "Second observation."}))
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() called twice must leave display active"
|
||||||
|
).is_true()
|
||||||
|
## Text label should reflect the second result
|
||||||
|
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
|
||||||
|
if text_label is RichTextLabel:
|
||||||
|
assert_that(text_label.text).override_failure_message(
|
||||||
|
"Second show_result() must replace the displayed text"
|
||||||
|
).is_equal("Second observation.")
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay: DISMISS_DELAY within spec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_dismiss_delay_within_spec() -> void:
|
||||||
|
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.DISMISS_DELAY).override_failure_message(
|
||||||
|
"DISMISS_DELAY must be 4–6 seconds per spec"
|
||||||
|
).is_between(4.0, 6.0)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_confidence_alpha_dict_covers_all_levels() -> void:
|
||||||
|
## All four confidence levels must have alpha mappings.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||||
|
assert_bool(alpha_dict.has(level)).override_failure_message(
|
||||||
|
"CONFIDENCE_ALPHA must map '%s'" % level
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_direct_is_highest() -> void:
|
||||||
|
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
|
||||||
|
"Direct confidence must have alpha 1.0 (brightest)"
|
||||||
|
).is_equal_approx(1.0, 0.001)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_suspects_is_lowest() -> void:
|
||||||
|
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
|
||||||
|
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
|
||||||
|
assert_float(suspects_alpha).override_failure_message(
|
||||||
|
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
|
||||||
|
).is_less(direct_alpha)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_all_values_valid() -> void:
|
||||||
|
## All alpha values must be in [0.0, 1.0].
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
for key in display.CONFIDENCE_ALPHA:
|
||||||
|
var alpha: float = display.CONFIDENCE_ALPHA[key]
|
||||||
|
assert_float(alpha).override_failure_message(
|
||||||
|
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
|
||||||
|
).is_between(0.0, 1.0)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-interactive: mouse_filter must be IGNORE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_mouse_filter_ignore() -> void:
|
||||||
|
## ExamineDisplay is non-interactive — must not consume mouse events.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_int(display.mouse_filter).override_failure_message(
|
||||||
|
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
|
||||||
|
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fade constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fade_in_is_short() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.FADE_IN).is_between(0.0, 0.5)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fade_out_is_short() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
|
||||||
|
display.queue_free()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
## GameState.apply_snapshot() tests — v2+ field coverage.
|
||||||
|
##
|
||||||
|
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
|
||||||
|
## player_position). This file covers v2+ fields and derived state.
|
||||||
|
##
|
||||||
|
## D-030: fixture-based, server-free, no subprocess required.
|
||||||
|
class_name TestGameState
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_each() -> void:
|
||||||
|
# Reset fields touched by these tests to known defaults.
|
||||||
|
GameState.current_tick = 0
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.player_facing = "North"
|
||||||
|
GameState.game_time = {}
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
GameState.current_monologue = null
|
||||||
|
GameState.player_stance = "Walk"
|
||||||
|
GameState.player_inventory = []
|
||||||
|
GameState.stationary_ticks = 0
|
||||||
|
GameState.insert_active = true
|
||||||
|
|
||||||
|
|
||||||
|
# -- v2: game_time (D-031) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_game_time() -> void:
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 10,
|
||||||
|
"entities": [],
|
||||||
|
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_that(GameState.game_time).is_not_null()
|
||||||
|
assert_that(GameState.game_time.get("day")).is_equal(3)
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
|
||||||
|
GameState.game_time = {"day": 2, "time_of_day": 360}
|
||||||
|
GameState.apply_snapshot({"tick": 5, "entities": []})
|
||||||
|
# No "game_time" key → field unchanged
|
||||||
|
assert_that(GameState.game_time.get("day")).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
# -- v2: player_facing (D-015) --------------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_player_facing() -> void:
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [],
|
||||||
|
"player_facing": "Southeast",
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_that(GameState.player_facing).is_equal("Southeast")
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||||
|
GameState.player_facing = "West"
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||||
|
assert_that(GameState.player_facing).is_equal("West")
|
||||||
|
|
||||||
|
|
||||||
|
# -- v4: nearby_interactions (#404/#405) ----------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||||
|
var interactions := [
|
||||||
|
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||||
|
]
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||||
|
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||||
|
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||||
|
GameState.nearby_interactions = [{"entity_id": 1}]
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_that(GameState.nearby_interactions.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- v5: current_monologue (#414) -----------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_monologue() -> void:
|
||||||
|
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||||
|
assert_that(GameState.current_monologue).is_not_null()
|
||||||
|
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_monologue_absent_clears_field() -> void:
|
||||||
|
GameState.current_monologue = {"id": "old", "text": "Old line."}
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_that(GameState.current_monologue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -- v6: player_stance (#449, D-053) --------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_player_stance() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
|
||||||
|
assert_that(GameState.player_stance).is_equal("Crouch")
|
||||||
|
|
||||||
|
|
||||||
|
# -- v6: player_inventory (#449, D-065) -----------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_sets_player_inventory() -> void:
|
||||||
|
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
|
||||||
|
assert_that(GameState.player_inventory.size()).is_equal(1)
|
||||||
|
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_inventory_absent_clears_list() -> void:
|
||||||
|
GameState.player_inventory = [{"item_id": 1}]
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_that(GameState.player_inventory.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Stationary tick counter (D-071) -------------------------------------
|
||||||
|
|
||||||
|
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
|
||||||
|
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||||
|
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_resets_on_player_movement() -> void:
|
||||||
|
var s1 := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
}
|
||||||
|
var s2 := {
|
||||||
|
"tick": 2,
|
||||||
|
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(s1)
|
||||||
|
GameState.apply_snapshot(s1) # stationary
|
||||||
|
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||||
|
GameState.apply_snapshot(s2) # moved → reset
|
||||||
|
assert_int(GameState.stationary_ticks).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_insert_active_false() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
|
||||||
|
assert_bool(GameState.insert_active).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
|
||||||
|
GameState.insert_active = false
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_bool(GameState.insert_active).is_true()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
## D-030 Layer 1: Cross-language IPC fixture tests (#271)
|
||||||
|
## Validates that Protocol.gd decodes the #271 named fixtures identically to Rust.
|
||||||
|
## Fixtures generated by: cargo test --test gen_fixtures -- --ignored
|
||||||
|
## Rust validation: server/tests/serialization.rs (fixture_* tests)
|
||||||
|
class_name TestIpcFixtures
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const FIXTURE_DIR = "res://tests/fixtures/msgpack/"
|
||||||
|
|
||||||
|
|
||||||
|
func _load_fixture(name: String) -> PackedByteArray:
|
||||||
|
var path = FIXTURE_DIR + name + ".msgpack"
|
||||||
|
var file = FileAccess.open(path, FileAccess.READ)
|
||||||
|
assert_that(file).is_not_null().override_failure_message(
|
||||||
|
"Fixture not found: %s — run 'make fixtures' to regenerate" % path
|
||||||
|
)
|
||||||
|
return file.get_buffer(file.get_length())
|
||||||
|
|
||||||
|
|
||||||
|
# -- snapshot_minimal ----------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_version() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot).is_not_null()
|
||||||
|
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_tick() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.tick).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_entity_count() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.entities.size()).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_entity_kind() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
var entity = snapshot.entities[0]
|
||||||
|
assert_that(entity.entity_id).is_equal(1)
|
||||||
|
# entity.kind is {"variant": "Player", "data": null} from _decode_enum_variant
|
||||||
|
assert_that(entity.kind.variant).is_equal("Player")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_no_monologue() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.current_monologue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_minimal_no_dialogue() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_minimal")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.dialogue_response).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -- snapshot_full -------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_tick() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot).is_not_null()
|
||||||
|
assert_that(snapshot.tick).is_equal(42)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_monologue_id() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.current_monologue).is_not_null()
|
||||||
|
assert_that(snapshot.current_monologue.id).is_equal("test_monologue_001")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_monologue_text() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.current_monologue.text).is_equal("Something feels off about this place.")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_dialogue_speaker() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.dialogue_response).is_not_null()
|
||||||
|
# dialogue_response has: line_id, text, speaker_entity_id (per protocol.gd v8 decode)
|
||||||
|
assert_that(snapshot.dialogue_response.speaker_entity_id).is_equal(99)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_inventory() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.player_inventory.size()).is_equal(1)
|
||||||
|
assert_that(snapshot.player_inventory[0].name).is_equal("Forged Customs Cert")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_poi_list() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.poi_list.size()).is_equal(1)
|
||||||
|
assert_that(snapshot.poi_list[0].poi_id).is_equal("docking_bay_7")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_player_knowledge_entity() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.player_knowledge).is_not_null()
|
||||||
|
assert_that(snapshot.player_knowledge.entities.size()).is_equal(1)
|
||||||
|
assert_that(snapshot.player_knowledge.entities[0].name).is_equal("Kael")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_snapshot_full_player_knowledge_fact() -> void:
|
||||||
|
var bytes = _load_fixture("snapshot_full")
|
||||||
|
var snapshot = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(snapshot.player_knowledge.facts.size()).is_equal(1)
|
||||||
|
assert_that(snapshot.player_knowledge.facts[0].fact_id).is_equal("poi.docking_bay_7")
|
||||||
|
|
||||||
|
|
||||||
|
# -- player_input_move ---------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fixture_player_input_move_tick() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_move")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
assert_that(input).is_not_null()
|
||||||
|
assert_that(input.tick).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_player_input_move_action() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_move")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
# action is {"variant": "MoveNorth", "data": null} from _decode_enum_variant
|
||||||
|
assert_that(input.action.variant).is_equal("MoveNorth")
|
||||||
|
|
||||||
|
|
||||||
|
# -- player_input_interact -----------------------------------------------------
|
||||||
|
|
||||||
|
func test_fixture_player_input_interact_tick() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_interact")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
assert_that(input).is_not_null()
|
||||||
|
assert_that(input.tick).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_player_input_interact_action() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_interact")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
# Interact is a struct variant: {"variant": "Interact", "data": {"target_entity_id": 99, "verb": "Talk"}}
|
||||||
|
assert_that(input.action.variant).is_equal("Interact")
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_player_input_interact_target() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_interact")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
assert_that(input.action.data.target_entity_id).is_equal(99)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_player_input_interact_verb() -> void:
|
||||||
|
var bytes = _load_fixture("player_input_interact")
|
||||||
|
var input = Protocol.decode_player_input(bytes)
|
||||||
|
assert_that(input.action.data.verb).is_equal("Talk")
|
||||||
|
|
||||||
|
|
||||||
|
# -- malformed -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fixture_malformed_snapshot_fails() -> void:
|
||||||
|
var bytes = _load_fixture("malformed")
|
||||||
|
# Intentionally truncated — decode_snapshot must return null (not crash)
|
||||||
|
var result = Protocol.decode_snapshot(bytes)
|
||||||
|
assert_that(result).is_null().override_failure_message(
|
||||||
|
"malformed fixture should not decode as a valid ObserverSnapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func test_fixture_malformed_input_fails() -> void:
|
||||||
|
var bytes = _load_fixture("malformed")
|
||||||
|
# Intentionally truncated — decode_player_input must return null (not crash)
|
||||||
|
var result = Protocol.decode_player_input(bytes)
|
||||||
|
assert_that(result).is_null().override_failure_message(
|
||||||
|
"malformed fixture should not decode as a valid PlayerInput"
|
||||||
|
)
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
## Sprint 18 — Minimap rendering (#151)
|
||||||
|
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||||
|
## D-049 (z-layer 6 = InsertOverlay)
|
||||||
|
##
|
||||||
|
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||||
|
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||||
|
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||||
|
##
|
||||||
|
## Tests run against live Stig implementation (minimap.gd).
|
||||||
|
class_name TestMinimapSprint18
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||||
|
|
||||||
|
func _make_minimap() -> Control:
|
||||||
|
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||||
|
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||||
|
return null
|
||||||
|
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||||
|
var base: Dictionary = {
|
||||||
|
"id": "poi_test_001",
|
||||||
|
"x": 20,
|
||||||
|
"y": 15,
|
||||||
|
"poi_category": "location",
|
||||||
|
"label": "Exit A",
|
||||||
|
}
|
||||||
|
base.merge(overrides, true)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.discovered_pois = []
|
||||||
|
GameState.player_position = Vector2(10.0, 10.0)
|
||||||
|
GameState.insert_active = true
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.discovered_pois = []
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.insert_active = true
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene and class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_minimap_scene_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||||
|
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_minimap_instantiates_without_crash() -> void:
|
||||||
|
var mm := _make_minimap()
|
||||||
|
if mm == null: return
|
||||||
|
assert_that(mm).is_not_null()
|
||||||
|
mm.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_minimap_is_minimap_renderer_class() -> void:
|
||||||
|
## class_name MinimapRenderer in minimap.gd.
|
||||||
|
var mm := _make_minimap()
|
||||||
|
if mm == null: return
|
||||||
|
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||||
|
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||||
|
).is_true()
|
||||||
|
mm.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants: D-015, visual parameters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_minimap_radius_constant() -> void:
|
||||||
|
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||||
|
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||||
|
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||||
|
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||||
|
).is_equal_approx(24.0, 0.01)
|
||||||
|
|
||||||
|
|
||||||
|
func test_player_dot_radius_defined() -> void:
|
||||||
|
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||||
|
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_poi_dot_radius_defined() -> void:
|
||||||
|
## POI dot must be visible (> 0).
|
||||||
|
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_player_dot_larger_than_poi_dot() -> void:
|
||||||
|
## D-015: Player is always centered and visually distinct.
|
||||||
|
## Player dot should be at least as large as POI dot.
|
||||||
|
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _category_color() — D-013 POI category color mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_category_color_danger_is_hostile_color() -> void:
|
||||||
|
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||||
|
for cat in ["danger", "threat", "hostile"]:
|
||||||
|
var color: Color = MinimapRenderer._category_color(cat)
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||||
|
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
|
||||||
|
|
||||||
|
func test_category_color_evidence_is_poi_color() -> void:
|
||||||
|
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||||
|
for cat in ["evidence", "note", "clue"]:
|
||||||
|
var color: Color = MinimapRenderer._category_color(cat)
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||||
|
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
|
||||||
|
|
||||||
|
func test_category_color_contact_is_unknown_color() -> void:
|
||||||
|
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||||
|
for cat in ["contact", "npc", "person"]:
|
||||||
|
var color: Color = MinimapRenderer._category_color(cat)
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||||
|
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||||
|
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||||
|
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||||
|
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_category_color_empty_string_defaults() -> void:
|
||||||
|
## Empty category string → default color, no crash.
|
||||||
|
var color: Color = MinimapRenderer._category_color("")
|
||||||
|
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_category_color_case_insensitive() -> void:
|
||||||
|
## Category matching is case-insensitive (uses to_lower()).
|
||||||
|
var danger_lower := MinimapRenderer._category_color("danger")
|
||||||
|
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||||
|
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||||
|
assert_that(danger_lower).is_equal(danger_upper)
|
||||||
|
assert_that(danger_lower).is_equal(danger_mixed)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# set_insert_active() — D-049: insert layer visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_set_insert_active_false_hides_minimap() -> void:
|
||||||
|
## When insert is inactive, minimap must be hidden.
|
||||||
|
var mm := _make_minimap()
|
||||||
|
if mm == null: return
|
||||||
|
mm.set_insert_active(false)
|
||||||
|
assert_bool(mm.visible).override_failure_message(
|
||||||
|
"set_insert_active(false) must hide the minimap"
|
||||||
|
).is_false()
|
||||||
|
mm.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_set_insert_active_true_shows_minimap() -> void:
|
||||||
|
## When insert is active, minimap must be visible.
|
||||||
|
var mm := _make_minimap()
|
||||||
|
if mm == null: return
|
||||||
|
mm.set_insert_active(false)
|
||||||
|
mm.set_insert_active(true)
|
||||||
|
assert_bool(mm.visible).override_failure_message(
|
||||||
|
"set_insert_active(true) must show the minimap"
|
||||||
|
).is_true()
|
||||||
|
mm.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main scene structural check: InsertOverlay/Minimap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||||
|
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||||
|
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||||
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||||
|
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
|
||||||
|
# Check for Minimap in InsertOverlay
|
||||||
|
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||||
|
assert_that(insert_overlay != null).override_failure_message(
|
||||||
|
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||||
|
).is_true()
|
||||||
|
if insert_overlay == null: return
|
||||||
|
|
||||||
|
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||||
|
assert_that(minimap != null).override_failure_message(
|
||||||
|
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||||
|
).is_true()
|
||||||
|
if minimap == null: return
|
||||||
|
|
||||||
|
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||||
|
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||||
|
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||||
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||||
|
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
|
||||||
|
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||||
|
if insert_overlay == null: return
|
||||||
|
assert_int(insert_overlay.layer).override_failure_message(
|
||||||
|
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||||
|
).is_equal(Constants.CANVAS_INSERT)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState.discovered_pois integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||||
|
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||||
|
"GameState must have 'discovered_pois' field (#151)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||||
|
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"poi_list": [
|
||||||
|
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||||
|
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||||
|
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||||
|
).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||||
|
## Snapshot with "discovered_pois" key also works.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 2,
|
||||||
|
"discovered_pois": [_make_poi()],
|
||||||
|
})
|
||||||
|
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||||
|
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||||
|
GameState.discovered_pois = [_make_poi()]
|
||||||
|
GameState.apply_snapshot({"tick": 3})
|
||||||
|
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||||
|
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||||
|
).is_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_discovered_pois_poi_category_field_present() -> void:
|
||||||
|
## MinimapRenderer reads poi_category to determine shape/color.
|
||||||
|
## Verify the wire format includes this field.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||||
|
})
|
||||||
|
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||||
|
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||||
|
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||||
|
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_discovered_pois_x_y_fields_present() -> void:
|
||||||
|
## MinimapRenderer reads x, y for position calculation.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||||
|
})
|
||||||
|
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||||
|
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||||
|
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||||
|
"POI entries must have 'x' and 'y' coordinate fields"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Color constants: all distinct
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_category_colors_are_distinct() -> void:
|
||||||
|
## All three primary category color groups must be visually distinct.
|
||||||
|
var danger_color := MinimapRenderer._category_color("danger")
|
||||||
|
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||||
|
var contact_color := MinimapRenderer._category_color("contact")
|
||||||
|
assert_that(danger_color).is_not_equal(evidence_color)
|
||||||
|
assert_that(evidence_color).is_not_equal(contact_color)
|
||||||
|
assert_that(danger_color).is_not_equal(contact_color)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
## Sprint 19 — Game session management (#258, D-085)
|
||||||
|
## Per-game save directories: created on New Game, resumed via game-id.
|
||||||
|
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||||
|
class_name TestSessionManagerSprint19
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
# Game IDs created during the current test — deleted in after_test().
|
||||||
|
var _created_ids: Array = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_game_id = ""
|
||||||
|
_created_ids = []
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
for game_id in _created_ids:
|
||||||
|
var path := "user://saves/" + game_id
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||||
|
_created_ids.clear()
|
||||||
|
GameState.current_game_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: call new_game() and track the created directory for cleanup.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _new_game() -> String:
|
||||||
|
var game_id := SessionManager.new_game()
|
||||||
|
if not game_id.is_empty():
|
||||||
|
_created_ids.append(game_id)
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState.current_game_id field
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_current_game_id_field_exists() -> void:
|
||||||
|
## D-085: GameState must have current_game_id field.
|
||||||
|
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||||
|
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_current_game_id_default_is_empty_string() -> void:
|
||||||
|
## Before any session starts, current_game_id is empty.
|
||||||
|
GameState.current_game_id = ""
|
||||||
|
assert_str(GameState.current_game_id).override_failure_message(
|
||||||
|
"GameState.current_game_id default must be empty string"
|
||||||
|
).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SessionManager autoload exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_session_manager_autoload_exists() -> void:
|
||||||
|
## SessionManager must be registered as an autoload.
|
||||||
|
var sm := Engine.get_singleton("SessionManager")
|
||||||
|
assert_that(sm != null).override_failure_message(
|
||||||
|
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# new_game() — game-id format and GameState update
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_new_game_returns_non_empty_string() -> void:
|
||||||
|
var game_id := _new_game()
|
||||||
|
assert_str(game_id).override_failure_message(
|
||||||
|
"SessionManager.new_game() must return a non-empty game-id string"
|
||||||
|
).is_not_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||||
|
var game_id := _new_game()
|
||||||
|
assert_str(GameState.current_game_id).override_failure_message(
|
||||||
|
"new_game() must set GameState.current_game_id"
|
||||||
|
).is_equal(game_id)
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_id_format_has_two_dashes() -> void:
|
||||||
|
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||||
|
var game_id := _new_game()
|
||||||
|
var parts := game_id.split("-")
|
||||||
|
assert_int(parts.size()).override_failure_message(
|
||||||
|
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||||
|
).is_equal(3)
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||||
|
## First part is YYYYMMDD — 8 decimal digits.
|
||||||
|
var game_id := _new_game()
|
||||||
|
var parts := game_id.split("-")
|
||||||
|
assert_int(parts[0].length()).override_failure_message(
|
||||||
|
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||||
|
).is_equal(8)
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||||
|
## Second part is HHMMSS — 6 decimal digits.
|
||||||
|
var game_id := _new_game()
|
||||||
|
var parts := game_id.split("-")
|
||||||
|
assert_int(parts[1].length()).override_failure_message(
|
||||||
|
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||||
|
).is_equal(6)
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||||
|
## Third part is 6 hex characters (RNG seed).
|
||||||
|
var game_id := _new_game()
|
||||||
|
var parts := game_id.split("-")
|
||||||
|
assert_int(parts[2].length()).override_failure_message(
|
||||||
|
"game-id third part (hex seed) must be 6 characters"
|
||||||
|
).is_equal(6)
|
||||||
|
|
||||||
|
|
||||||
|
func test_new_game_ids_are_unique() -> void:
|
||||||
|
## Two rapid new_game() calls should produce different IDs
|
||||||
|
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||||
|
var id1 := _new_game()
|
||||||
|
var id2 := _new_game()
|
||||||
|
# Check that hex seeds differ (they almost certainly will)
|
||||||
|
var seed1 := id1.split("-")[2]
|
||||||
|
var seed2 := id2.split("-")[2]
|
||||||
|
assert_str(seed1).override_failure_message(
|
||||||
|
"Successive new_game() calls should have different RNG seeds"
|
||||||
|
).is_not_equal(seed2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resume_game() — sets GameState.current_game_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_resume_game_sets_current_game_id() -> void:
|
||||||
|
var test_id := "20260225-143022-a7b3f1"
|
||||||
|
SessionManager.resume_game(test_id)
|
||||||
|
assert_str(GameState.current_game_id).override_failure_message(
|
||||||
|
"resume_game() must set GameState.current_game_id to the given id"
|
||||||
|
).is_equal(test_id)
|
||||||
|
|
||||||
|
|
||||||
|
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||||
|
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||||
|
SessionManager.resume_game("20260225-120000-112233")
|
||||||
|
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main menu scene
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_main_menu_scene_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||||
|
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_main_menu_instantiates_without_crash() -> void:
|
||||||
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
|
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
assert_that(scene).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_main_menu_has_new_game_button() -> void:
|
||||||
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||||
|
assert_that(btn != null).override_failure_message(
|
||||||
|
"Main menu must have VBox/NewGameBtn (#258)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_main_menu_has_continue_button() -> void:
|
||||||
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
|
return
|
||||||
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||||
|
auto_free(scene)
|
||||||
|
add_child(scene)
|
||||||
|
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||||
|
assert_that(btn != null).override_failure_message(
|
||||||
|
"Main menu must have VBox/ContinueBtn (#258)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Project main scene changed to main_menu.tscn
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_project_main_scene_is_main_menu() -> void:
|
||||||
|
## D-085: project boots to main menu, not directly to game scene.
|
||||||
|
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||||
|
assert_str(scene_path).override_failure_message(
|
||||||
|
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||||
|
).is_equal("res://scenes/main_menu.tscn")
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
## Scene testing utilities for gdUnit4 tests.
|
||||||
|
##
|
||||||
|
## Loads a scene, instantiates it into the test suite's node tree,
|
||||||
|
## and provides helpers for node existence, signal, and node-path queries.
|
||||||
|
##
|
||||||
|
## Usage (from a GdUnitTestSuite subclass):
|
||||||
|
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
|
||||||
|
## helper.assert_node_exists("World")
|
||||||
|
## var world := helper.get_node_at("World")
|
||||||
|
## helper.monitor_signal(world, "ready")
|
||||||
|
## # ... trigger something ...
|
||||||
|
## helper.assert_signal_emitted(world, "ready")
|
||||||
|
##
|
||||||
|
## Design constraints (D-030): server-free, no running autoload dependencies.
|
||||||
|
class_name SceneHelper
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
|
||||||
|
var _scene: Node
|
||||||
|
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
|
||||||
|
var _signal_hits: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
## Load, instantiate, and attach a scene to the test suite's node tree.
|
||||||
|
## The scene node is registered for auto-free by gdUnit4.
|
||||||
|
## Returns a helper instance; fails the test if the scene cannot be loaded.
|
||||||
|
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
|
||||||
|
var helper := SceneHelper.new()
|
||||||
|
helper._suite = suite
|
||||||
|
|
||||||
|
var packed: PackedScene = load(scene_path)
|
||||||
|
if packed == null:
|
||||||
|
suite.assert_that(packed).override_failure_message(
|
||||||
|
"SceneHelper: could not load scene at '%s'" % scene_path
|
||||||
|
).is_not_null()
|
||||||
|
return helper
|
||||||
|
|
||||||
|
helper._scene = packed.instantiate()
|
||||||
|
suite.auto_free(helper._scene)
|
||||||
|
suite.add_child(helper._scene)
|
||||||
|
return helper
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the scene root node.
|
||||||
|
func scene() -> Node:
|
||||||
|
return _scene
|
||||||
|
|
||||||
|
|
||||||
|
## Assert that a node at node_path exists under the scene root.
|
||||||
|
## Fails the current test if the node is absent.
|
||||||
|
func assert_node_exists(node_path: String) -> void:
|
||||||
|
var node := _scene.get_node_or_null(NodePath(node_path))
|
||||||
|
_suite.assert_that(node).override_failure_message(
|
||||||
|
"SceneHelper: expected node at path '%s' — not found" % node_path
|
||||||
|
).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
## Return the node at node_path under the scene root, or null if absent.
|
||||||
|
func get_node_at(node_path: String) -> Node:
|
||||||
|
return _scene.get_node_or_null(NodePath(node_path))
|
||||||
|
|
||||||
|
|
||||||
|
## Begin tracking emissions of signal_name on node.
|
||||||
|
## Must be called before the action that triggers the signal.
|
||||||
|
## Fails the test if node does not have the named signal.
|
||||||
|
func monitor_signal(node: Node, signal_name: String) -> void:
|
||||||
|
if not node.has_signal(signal_name):
|
||||||
|
_suite.assert_that(false).override_failure_message(
|
||||||
|
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
|
||||||
|
).is_true()
|
||||||
|
return
|
||||||
|
var key := _signal_key(node, signal_name)
|
||||||
|
_signal_hits[key] = 0
|
||||||
|
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
|
||||||
|
# GDScript default-param lambdas handle being called with fewer args correctly.
|
||||||
|
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
|
||||||
|
_signal_hits[key] = _signal_hits.get(key, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Assert that signal_name was emitted at least once since monitor_signal().
|
||||||
|
## Fails the test if monitor_signal() was not called first, or if count is zero.
|
||||||
|
func assert_signal_emitted(node: Node, signal_name: String) -> void:
|
||||||
|
var key := _signal_key(node, signal_name)
|
||||||
|
if not _signal_hits.has(key):
|
||||||
|
_suite.assert_that(false).override_failure_message(
|
||||||
|
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
|
||||||
|
).is_true()
|
||||||
|
return
|
||||||
|
var count: int = _signal_hits[key]
|
||||||
|
_suite.assert_int(count).override_failure_message(
|
||||||
|
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
|
||||||
|
signal_name, node.name, count
|
||||||
|
]
|
||||||
|
).is_greater(0)
|
||||||
|
|
||||||
|
|
||||||
|
static func _signal_key(node: Node, signal_name: String) -> String:
|
||||||
|
return "%d:%s" % [node.get_instance_id(), signal_name]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
extends Control
|
||||||
|
## #258: Main menu — New Game / Continue / Quit.
|
||||||
|
## New Game: generates per-game save directory (D-085), starts game.
|
||||||
|
## Continue: loads most recent save directory (#554 full loading screen deferred).
|
||||||
|
|
||||||
|
const GAME_SCENE := "res://scenes/main.tscn"
|
||||||
|
|
||||||
|
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||||
|
const TITLE_COLOR := Color("#c8d0e0")
|
||||||
|
const SUBTITLE_COLOR := Color("#8890a0")
|
||||||
|
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||||
|
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||||
|
const FONT_SIZE_TITLE := 36
|
||||||
|
const FONT_SIZE_SUBTITLE := 14
|
||||||
|
const FONT_SIZE_BTN := 15
|
||||||
|
|
||||||
|
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||||
|
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||||
|
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_new_game_btn.pressed.connect(_on_new_game)
|
||||||
|
_continue_btn.pressed.connect(_on_continue)
|
||||||
|
_quit_btn.pressed.connect(_on_quit)
|
||||||
|
_refresh_continue_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_continue_state() -> void:
|
||||||
|
var saves := SessionManager.list_game_dirs()
|
||||||
|
_continue_btn.disabled = saves.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_new_game() -> void:
|
||||||
|
var game_id := SessionManager.new_game()
|
||||||
|
if game_id.is_empty():
|
||||||
|
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
||||||
|
return
|
||||||
|
get_tree().change_scene_to_file(GAME_SCENE)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_continue() -> void:
|
||||||
|
# #554: Full loading screen blocked until server SaveCommand lands.
|
||||||
|
# For now: automatically load the most recent game directory.
|
||||||
|
var saves := SessionManager.list_game_dirs()
|
||||||
|
if saves.is_empty():
|
||||||
|
return
|
||||||
|
SessionManager.resume_game(saves[0].game_id)
|
||||||
|
get_tree().change_scene_to_file(GAME_SCENE)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quit() -> void:
|
||||||
|
get_tree().quit()
|
||||||
@@ -91,7 +91,7 @@ func _draw() -> void:
|
|||||||
var dx: float = float(poi.x) - px
|
var dx: float = float(poi.x) - px
|
||||||
var dy: float = float(poi.y) - py
|
var dy: float = float(poi.y) - py
|
||||||
var dist: float = sqrt(dx * dx + dy * dy)
|
var dist: float = sqrt(dx * dx + dy * dy)
|
||||||
var category: String = poi.get("category", "")
|
var category: String = poi.get("poi_category", "")
|
||||||
var color: Color = _category_color(category)
|
var color: Color = _category_color(category)
|
||||||
|
|
||||||
if dist < 0.01:
|
if dist < 0.01:
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = {
|
|||||||
}
|
}
|
||||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||||
|
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||||
|
const _NOTIFICATION_DURATION: float = 2.5
|
||||||
|
|
||||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||||
|
|
||||||
@@ -62,7 +64,10 @@ func _process(delta: float) -> void:
|
|||||||
var now := float(Time.get_ticks_msec())
|
var now := float(Time.get_ticks_msec())
|
||||||
if now >= _next_fade_in_msec:
|
if now >= _next_fade_in_msec:
|
||||||
var next: Dictionary = _queue.pop_front()
|
var next: Dictionary = _queue.pop_front()
|
||||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
if next.get("is_notification", false):
|
||||||
|
_show_notification_line(next.text)
|
||||||
|
else:
|
||||||
|
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||||
|
|
||||||
|
|
||||||
# Display a monologue line.
|
# Display a monologue line.
|
||||||
@@ -71,6 +76,26 @@ func _process(delta: float) -> void:
|
|||||||
# Empty text is silently ignored — no slot created, no queue entry.
|
# Empty text is silently ignored — no slot created, no queue entry.
|
||||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||||
# decoupled from the autoload (D-020 renderer contract).
|
# decoupled from the autoload (D-020 renderer contract).
|
||||||
|
# #554: Show a brief system notification (save/load result, connection status).
|
||||||
|
# Uses neutral color, short duration, bypasses lattice_profile styling.
|
||||||
|
func show_notification(text: String) -> void:
|
||||||
|
if text.is_empty():
|
||||||
|
return
|
||||||
|
var now := float(Time.get_ticks_msec())
|
||||||
|
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||||
|
_show_notification_line(text)
|
||||||
|
else:
|
||||||
|
var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true}
|
||||||
|
if _queue.size() < MAX_QUEUE:
|
||||||
|
_queue.append(entry)
|
||||||
|
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||||
|
else:
|
||||||
|
var lowest := _lowest_priority_idx()
|
||||||
|
if 1 >= _queue[lowest].priority:
|
||||||
|
_queue[lowest] = entry
|
||||||
|
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||||
|
|
||||||
|
|
||||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||||
if text.is_empty():
|
if text.is_empty():
|
||||||
return
|
return
|
||||||
@@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
|||||||
# Internal
|
# Internal
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _show_notification_line(text: String) -> void:
|
||||||
|
var container := MarginContainer.new()
|
||||||
|
container.add_theme_constant_override("margin_left", 4)
|
||||||
|
container.add_theme_constant_override("margin_right", 4)
|
||||||
|
container.add_theme_constant_override("margin_top", 2)
|
||||||
|
container.add_theme_constant_override("margin_bottom", 2)
|
||||||
|
var label := RichTextLabel.new()
|
||||||
|
label.bbcode_enabled = true
|
||||||
|
label.fit_content = true
|
||||||
|
label.scroll_active = false
|
||||||
|
label.add_theme_font_size_override("normal_font_size", 13)
|
||||||
|
var safe_text := text.replace("[", "[lb]")
|
||||||
|
label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text]
|
||||||
|
container.add_child(label)
|
||||||
|
_vbox.add_child(container)
|
||||||
|
var slot := {
|
||||||
|
node = container,
|
||||||
|
expire_timer = _NOTIFICATION_DURATION,
|
||||||
|
priority = 1,
|
||||||
|
tween = null,
|
||||||
|
}
|
||||||
|
_visible.append(slot)
|
||||||
|
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||||
|
container.modulate.a = 0.0
|
||||||
|
var tween := create_tween()
|
||||||
|
slot.tween = tween
|
||||||
|
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
|
||||||
|
|
||||||
|
|
||||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||||
_vbox.add_child(line_node)
|
_vbox.add_child(line_node)
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
|||||||
close_btn.pressed.connect(close)
|
close_btn.pressed.connect(close)
|
||||||
_container.add_child(close_btn)
|
_container.add_child(close_btn)
|
||||||
|
|
||||||
|
# Quit to Menu button (#258: D-085 session management)
|
||||||
|
var quit_btn := Button.new()
|
||||||
|
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
|
||||||
|
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||||
|
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
|
||||||
|
quit_btn.pressed.connect(_on_quit_to_menu)
|
||||||
|
_container.add_child(quit_btn)
|
||||||
|
|
||||||
|
|
||||||
func _destroy_ui() -> void:
|
func _destroy_ui() -> void:
|
||||||
if _container:
|
if _container:
|
||||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
|||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quit_to_menu() -> void:
|
||||||
|
close()
|
||||||
|
SessionManager.quit_to_menu()
|
||||||
|
|
||||||
|
|
||||||
static func _format_db(db: float) -> String:
|
static func _format_db(db: float) -> String:
|
||||||
if db <= -40.0:
|
if db <= -40.0:
|
||||||
return "mute"
|
return "mute"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Fact catalog: ring
|
||||||
|
# Smuggling ring module event trail — observable evidence produced by module events.
|
||||||
|
# These facts are made discoverable by specific events in smuggling_ring_v0_1.yaml.
|
||||||
|
# Stub entries — description and confidence level confirmed; progression text TBD.
|
||||||
|
# Ticket: #158 | Sprint 18
|
||||||
|
|
||||||
|
facts:
|
||||||
|
|
||||||
|
- fact_id: ring.cargo_discrepancy_pattern
|
||||||
|
description: Cargo manifests at The Terminal show small, systematic discrepancies — weight variances, unverified entries, containers with no return log
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Detective's analytical lattice may flag this automatically during Terminal walkthrough.
|
||||||
|
Smuggler encounters it during operational paperwork checks. Pattern becomes clearer over
|
||||||
|
multiple observations (once: false event — repeats). Starting entry point for both
|
||||||
|
investigation arcs.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_missed_verification
|
||||||
|
description: Kael Davan was absent from a scheduled cargo verification — another dock worker covered without explanation
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Discoverable via cargo manifest review (Terminal office) or analytical lattice flag
|
||||||
|
(detective). Smuggler notices the irregularity in their own paperwork. First concrete
|
||||||
|
evidence that Kael is deviating from his normal operational reliability.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_unauthorized_corridor_access
|
||||||
|
description: Kael was observed in restricted maintenance corridor B-7 during off-shift hours with an unknown contact
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Core FRIEND contradiction observable (D-034). Player must be in visual range of B-7
|
||||||
|
to discover this, OR examine the corridor door access log (investigative action).
|
||||||
|
Both characters can discover this fact — interpreted differently (smuggler: security
|
||||||
|
breach / detective: suspicious contact). Upgrades from suspects to knows_of when
|
||||||
|
contact identity confirmed, knows_details when purpose confirmed.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_unusual_meridian_activity
|
||||||
|
description: Encrypted Meridian packets sent from cargo bay terminals — frequent, patterned, not from personal devices
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only. Requires analytical lattice to flag the outgoing packet pattern.
|
||||||
|
Player must be in or adjacent to The Terminal cargo bay. Content not accessible —
|
||||||
|
only the existence and frequency of the encrypted messages. Hints at Kael's
|
||||||
|
off-district contact without revealing who.
|
||||||
|
|
||||||
|
- fact_id: ring.voss_kael_private_meeting
|
||||||
|
description: Voss called Kael into the supervisor's office — closed door, raised voices audible from adjacent position
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Observable via spatial positioning (supervisor's office area). Smuggler can witness
|
||||||
|
the approach and departure without hearing content. Detective observes Kael's visible
|
||||||
|
strain immediately after if present in The Terminal. Upgrade to knows_of via
|
||||||
|
trust-gated dialogue with Kael ("Are you alright?" option unlocks post-meeting).
|
||||||
|
|
||||||
|
- fact_id: ring.voss_accelerating_timeline
|
||||||
|
description: Voss is pushing to close ring operations faster than normal — someone is watching
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Smuggler: Voss mentions "the schedule moving up" in guarded conversation (trust-gated,
|
||||||
|
ring-insider access required). Detective: observe Voss and Nils in two exchanges within
|
||||||
|
the same shift and note behavioral change. Not directly stated — inferred from context.
|
||||||
|
|
||||||
|
- fact_id: ring.commission_cargo_flag
|
||||||
|
description: A Commission internal note flags The Terminal's cargo variance rate as statistically unusual
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only. Accessible via institutional Commission query (authority access tier,
|
||||||
|
Terminal records). Not a formal investigation — just an internal flag from Maret Korr.
|
||||||
|
Confirms the detective's suspicions with institutional weight. Smuggler has no way to
|
||||||
|
know this exists.
|
||||||
|
|
||||||
|
- fact_id: ring.final_shipment_scheduled
|
||||||
|
description: The ring has scheduled a major final drop — after it clears, operations go dark
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Smuggler: direct notification from Voss (ring-insider access). Detective: cargo manifest
|
||||||
|
shows an unusually large entry scheduled 3 days out — no obvious legitimate reason for
|
||||||
|
the volume. This fact creates the closing window — both characters know time is running
|
||||||
|
out, for different reasons.
|
||||||
|
|
||||||
|
- fact_id: ring.sera_avoidance_behavior
|
||||||
|
description: Sera Venn takes longer routes through The Last Shift to avoid standing near Torek Lintar
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only observable. Player must observe Sera's movement pattern on two separate
|
||||||
|
occasions — requires forward vision cone and awareness of her baseline route. The
|
||||||
|
avoidance is visible but not explained. Points toward Sera holding information about
|
||||||
|
Torek's investigation without having acted on it.
|
||||||
|
|
||||||
|
- fact_id: ring.nils_unlogged_cargo
|
||||||
|
description: Nils makes regular supply runs to maintenance corridor C-7 with containers that never appear in return logs
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Discoverable by watching Nils's cargo handling pattern over two shifts, or by examining
|
||||||
|
the maintenance corridor C-7 access log directly. The missing return entry is the tell —
|
||||||
|
the container went in but never came back. Both characters can find this; smuggler may
|
||||||
|
already suspect given operational context.
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
|
||||||
|
#
|
||||||
|
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
|
||||||
|
# The vertical slice Tier 1 module for D-027.
|
||||||
|
#
|
||||||
|
# NARRATIVE CORE:
|
||||||
|
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
|
||||||
|
# smuggling unlicensed lattice components through Sova Transit District (D-037).
|
||||||
|
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
|
||||||
|
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
|
||||||
|
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
|
||||||
|
# but hasn't reported them, protecting Naia by proxy.
|
||||||
|
#
|
||||||
|
# DUAL-LENS EXPERIENCE:
|
||||||
|
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
|
||||||
|
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
|
||||||
|
# confront or protect.
|
||||||
|
#
|
||||||
|
# SUCCESS CRITERIA (D-027):
|
||||||
|
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
|
||||||
|
# #3: Player names Kael as an NPC they felt conflicted about
|
||||||
|
# #4: observe→notice→follow→discover emerges from systems, not scripts
|
||||||
|
|
||||||
|
module_id: smuggling_ring_v0_1
|
||||||
|
display_name: "The Smuggling Ring"
|
||||||
|
version: "0.1"
|
||||||
|
tier: 1
|
||||||
|
description: >
|
||||||
|
A small ring of logistics workers smuggling unlicensed lattice components through
|
||||||
|
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
|
||||||
|
to protect his partner. The detective investigates cargo anomalies. The smuggler
|
||||||
|
manages ring operations and navigates Kael's loyalty crisis. Neither character
|
||||||
|
knows the other's full picture until confrontation forces it.
|
||||||
|
|
||||||
|
notes: >
|
||||||
|
This module IS the vertical slice (D-027). It exercises every system at full depth:
|
||||||
|
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
|
||||||
|
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
|
||||||
|
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
|
||||||
|
No outcome is "the right answer" — Kael's situation has no clean resolution.
|
||||||
|
|
||||||
|
dual_lens:
|
||||||
|
smuggler: >
|
||||||
|
You're inside the ring. Voss manages operations; you handle logistics cover.
|
||||||
|
Kael used to be reliable. Lately he's absent, distracted, making excuses.
|
||||||
|
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
|
||||||
|
You don't know he's trying to get out. He doesn't know you've noticed.
|
||||||
|
detective: >
|
||||||
|
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
|
||||||
|
Your analytical lattice flags them before your conscious mind does.
|
||||||
|
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
|
||||||
|
maintenance corridors → someone he shouldn't be meeting. And then what?
|
||||||
|
Arrest a man trying to leave a ring he never wanted to join?
|
||||||
|
|
||||||
|
pool:
|
||||||
|
weight: 8
|
||||||
|
compatible_districts:
|
||||||
|
- sova-transit
|
||||||
|
max_concurrent: 1
|
||||||
|
|
||||||
|
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
|
||||||
|
# Ring activity begins after player has had time to establish routine (D-027 #1).
|
||||||
|
# The ring is already running at game start — the module activates when the
|
||||||
|
# storyteller decides the tension has built enough to surface.
|
||||||
|
|
||||||
|
entry_conditions:
|
||||||
|
world_state:
|
||||||
|
- type: npc_present
|
||||||
|
role: ring-leader
|
||||||
|
- type: npc_present
|
||||||
|
role: ring-member-exiting
|
||||||
|
- type: location_accessible
|
||||||
|
location: the-terminal
|
||||||
|
- type: location_accessible
|
||||||
|
location: maintenance-corridors
|
||||||
|
|
||||||
|
activation:
|
||||||
|
trigger: storyteller_push
|
||||||
|
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
|
||||||
|
# The storyteller pushes activation when player has established presence
|
||||||
|
# in The Terminal or The Last Shift through routine interaction.
|
||||||
|
# Proximity trigger (maintenance-corridors) is a secondary activation path
|
||||||
|
# if the player wanders there early.
|
||||||
|
|
||||||
|
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
|
||||||
|
# All core roles are named (hand-authored NPCs from the vertical slice).
|
||||||
|
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
|
||||||
|
|
||||||
|
npc_requirements:
|
||||||
|
- role: ring-leader
|
||||||
|
display_hint: >
|
||||||
|
Runs the ring from The Terminal. Logistics authority = cover.
|
||||||
|
Never handles contraband directly. Pressure source for Kael.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:voss"
|
||||||
|
must_have_motivation: HANDLER
|
||||||
|
|
||||||
|
- role: ring-member-exiting
|
||||||
|
display_hint: >
|
||||||
|
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
|
||||||
|
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
|
||||||
|
Every event sequence runs through this role.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:kael-davan"
|
||||||
|
must_have_pattern: FRIEND
|
||||||
|
must_have_motivation: TURNCOAT
|
||||||
|
|
||||||
|
- role: partner-uninvolved
|
||||||
|
display_hint: >
|
||||||
|
Naia Tamm. Kael's partner. Does not know about the ring.
|
||||||
|
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
|
||||||
|
Discovery of her connection to Kael is a late-investigation revelation.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:naia-tamm"
|
||||||
|
must_have_motivation: CIVILIAN
|
||||||
|
|
||||||
|
- role: evidence-holder
|
||||||
|
display_hint: >
|
||||||
|
Sera Venn. Detective's FRIEND. Commission field tech.
|
||||||
|
She has noticed Kael's manifest discrepancies but hasn't reported them —
|
||||||
|
she knows Naia, and filing means Kael's arrest and Naia's exposure.
|
||||||
|
Her silence IS the detective's investigation blocker in phase 1.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:sera-venn"
|
||||||
|
must_have_pattern: FRIEND
|
||||||
|
must_have_motivation: WITNESS
|
||||||
|
|
||||||
|
- role: ring-operative
|
||||||
|
display_hint: >
|
||||||
|
The ring's operational member in maintenance corridors.
|
||||||
|
Handles physical drops. Not a speaking character — observable behavior only.
|
||||||
|
Can be the anonymous contact Kael meets.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:nils-davan"
|
||||||
|
is_optional: false
|
||||||
|
|
||||||
|
- role: institutional-watcher
|
||||||
|
display_hint: >
|
||||||
|
Maret Korr. A Commission observer embedded at The Terminal.
|
||||||
|
Her growing attention is the external pressure that accelerates the timeline.
|
||||||
|
She doesn't know about the ring specifically — she's tracking cargo patterns.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:maret-korr"
|
||||||
|
must_have_motivation: OPERATOR
|
||||||
|
is_optional: true # Module runs without Maret, but with degraded tension arc
|
||||||
|
|
||||||
|
# ── EVENTS ───────────────────────────────────────────────────────────────────
|
||||||
|
# Two sequences + one pool.
|
||||||
|
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
|
||||||
|
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
|
||||||
|
# Pool: ambient ring activity (fires opportunistically throughout the module)
|
||||||
|
|
||||||
|
events:
|
||||||
|
|
||||||
|
sequences:
|
||||||
|
|
||||||
|
# SEQUENCE A: Kael's Exit Arc
|
||||||
|
# The narrative spine. Each step makes Kael's contradiction more visible.
|
||||||
|
# Observable to both characters, interpreted differently.
|
||||||
|
|
||||||
|
- sequence_id: kael_exit_arc
|
||||||
|
label: "Kael's Exit Arc"
|
||||||
|
description: >
|
||||||
|
Kael Davan's progressive attempt to leave the ring.
|
||||||
|
Tells intensify. Routine deviations appear. The secret meeting is the
|
||||||
|
pivot point — after it fires, both characters' understanding shifts.
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- event_id: kael_goes_cold
|
||||||
|
label: "Kael Goes Cold"
|
||||||
|
description: >
|
||||||
|
Kael starts missing social patterns he'd normally keep — fewer bar visits,
|
||||||
|
shorter responses at The Terminal, leaving early. His tell system activates:
|
||||||
|
the shoulder-check behavior appears. Nothing dramatic. Just absence where
|
||||||
|
there was presence. The smuggler notices because they work together.
|
||||||
|
The detective might notice if they've been tracking Kael's baseline.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 300 # ~5 minutes after module activates
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael skips his usual post-shift drink at The Last Shift.
|
||||||
|
Leaves the terminal 15 minutes early. No explanation.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's shoulder-check behavior activates at The Terminal.
|
||||||
|
Visible to any character with forward vision cone in his direction.
|
||||||
|
sets_flag: kael_behavior_changed
|
||||||
|
|
||||||
|
- event_id: drop_happens_without_kael
|
||||||
|
label: "Scheduled Drop — Kael Absent"
|
||||||
|
description: >
|
||||||
|
A ring drop occurs in maintenance corridor C-7. Kael was supposed
|
||||||
|
to verify the cargo. He wasn't there. Nils covered it.
|
||||||
|
The smuggler notices the irregularity in the paperwork.
|
||||||
|
The detective — if watching cargo patterns — sees a manifest entry
|
||||||
|
with no verifying signature where one is normally present.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_goes_cold
|
||||||
|
ticks: 450 # ~7.5 minutes after goes-cold
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_missed_verification"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: check the cargo manifest in The Terminal office.
|
||||||
|
Detective: analytical lattice flags unsigned verification entry.
|
||||||
|
- type: location_state
|
||||||
|
location: maintenance-corridors
|
||||||
|
description: "An unsigned cargo verification entry exists in corridor C-7's log."
|
||||||
|
sets_flag: kael_missed_drop
|
||||||
|
|
||||||
|
- event_id: kael_secret_meeting
|
||||||
|
label: "Kael's Secret Meeting"
|
||||||
|
description: >
|
||||||
|
Kael meets an off-district contact in maintenance corridor B-7.
|
||||||
|
This is the observable contradiction (D-034): Kael, in a restricted
|
||||||
|
area he has no logged reason to be in, talking to someone who's
|
||||||
|
not in any district NPC roster. His body language is tense.
|
||||||
|
If the player is in visual range: this is the pivot moment.
|
||||||
|
If not: the meeting happens anyway — the world doesn't wait.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: drop_happens_without_kael
|
||||||
|
ticks: 600 # ~10 minutes after the dropped verification
|
||||||
|
- type: player_proximity
|
||||||
|
target_type: location
|
||||||
|
target: maintenance-corridors
|
||||||
|
radius_tiles: 12 # Player wandering near triggers the meeting early
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael enters maintenance corridor B-7. Locked door to restricted
|
||||||
|
supply closet. Emerges with the ring-operative 8 minutes later.
|
||||||
|
Neither acknowledges the encounter publicly.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_unauthorized_corridor_access"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Player must be in visual range of corridor B-7.
|
||||||
|
Or examine the corridor door access log (investigative action).
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
After the meeting, Kael's shoulder-check frequency doubles.
|
||||||
|
Also: he avoids eye contact with the smuggler at The Terminal.
|
||||||
|
sets_flag: secret_meeting_occurred
|
||||||
|
|
||||||
|
- event_id: kael_sends_message
|
||||||
|
label: "Kael Sends the Message"
|
||||||
|
description: >
|
||||||
|
Kael sends an encrypted Meridian message to an off-district contact.
|
||||||
|
The detective's analytical lattice can detect an anomalous outgoing
|
||||||
|
packet from the district node — not the content, just the pattern
|
||||||
|
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
|
||||||
|
The smuggler won't see this unless they're specifically watching Kael.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_secret_meeting
|
||||||
|
ticks: 200
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_unusual_meridian_activity"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Detective's analytical lattice flags the outgoing packet pattern.
|
||||||
|
Requires player to be in or adjacent to The Terminal cargo bay.
|
||||||
|
sets_flag: kael_message_sent
|
||||||
|
|
||||||
|
- event_id: ring_leader_confronts_kael
|
||||||
|
label: "Voss Confronts Kael"
|
||||||
|
description: >
|
||||||
|
Voss calls Kael into The Terminal supervisor's office.
|
||||||
|
Closed door. Raised voices (audible only from adjacent room/position).
|
||||||
|
Kael emerges pale. Voss emerges neutral. The smuggler can witness
|
||||||
|
the approach/departure without hearing content. The detective can
|
||||||
|
observe Kael's state immediately after if in The Terminal.
|
||||||
|
This is Voss applying pressure. Kael is now visibly under strain.
|
||||||
|
triggers:
|
||||||
|
- type: flag_set
|
||||||
|
flag: kael_message_sent
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_message_sent
|
||||||
|
ticks: 400
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss calls Kael into the supervisor's office. Door closed."
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael emerges from the meeting looking strained. His shoulder-check
|
||||||
|
is now constant. He takes an unscheduled break outside, alone.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's contentment hits lowest observed level. He now actively avoids
|
||||||
|
the ring-operative (Nils) in public. The disconnection is visible.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.voss_kael_private_meeting"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Observe the meeting room door (spatial). Or ask Kael directly
|
||||||
|
after (trust-gated dialogue unlocks "Are you alright?" option).
|
||||||
|
sets_flag: voss_pressure_applied
|
||||||
|
|
||||||
|
# SEQUENCE B: Investigation Pressure Arc
|
||||||
|
# External pressure that escalates the timeline.
|
||||||
|
# Fires in parallel with Sequence A.
|
||||||
|
|
||||||
|
- sequence_id: investigation_pressure
|
||||||
|
label: "Investigation Pressure Arc"
|
||||||
|
description: >
|
||||||
|
Maret Korr's institutional attention creates a closing window.
|
||||||
|
Her growing interest is the reason the module can't stay in equilibrium forever.
|
||||||
|
She doesn't know about the ring — she's a pattern-watcher. But patterns
|
||||||
|
are what the detective investigates too. Their paths converge.
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- event_id: maret_flags_anomaly
|
||||||
|
label: "Maret Flags the Cargo Anomaly"
|
||||||
|
description: >
|
||||||
|
Maret Korr files an internal Commission note flagging The Terminal's
|
||||||
|
cargo variance rate as statistically unusual. Not an investigation —
|
||||||
|
just a flag. The detective's institutional access can pull this note.
|
||||||
|
The smuggler has no way to know it exists (unless the detective tells them).
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 900 # ~15 minutes after activation
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.commission_cargo_flag"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Detective queries Commission data via institutional access
|
||||||
|
(authority access tier, Terminal records).
|
||||||
|
sets_flag: commission_flag_exists
|
||||||
|
|
||||||
|
- event_id: maret_increases_presence
|
||||||
|
label: "Maret Increases Her Presence"
|
||||||
|
description: >
|
||||||
|
Maret starts spending more time in The Terminal. More frequent
|
||||||
|
walkthroughs during shift changes. Her attention to the cargo bay
|
||||||
|
area is noticeable to anyone watching. Ring members are unnerved.
|
||||||
|
Voss starts accelerating the timeline to close operations before
|
||||||
|
institutional attention becomes formal investigation.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: maret_flags_anomaly
|
||||||
|
ticks: 600
|
||||||
|
- type: player_action
|
||||||
|
action: examine
|
||||||
|
target_role: institutional-watcher
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: institutional-watcher
|
||||||
|
description: >
|
||||||
|
Maret adds two extra Terminal walkthroughs per shift cycle.
|
||||||
|
Spends 15 minutes studying the cargo bay manifest terminals.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: >
|
||||||
|
Voss becomes quieter, more deliberate. Less casual conversation.
|
||||||
|
His tell — the stillness before speaking — becomes more frequent.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.voss_accelerating_timeline"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: Voss mentions "the schedule moving up" in a guarded
|
||||||
|
conversation (trust-gated, ring-insider access required).
|
||||||
|
Detective: observe Voss and Nils in two exchanges within same shift.
|
||||||
|
sets_flag: timeline_accelerating
|
||||||
|
|
||||||
|
- event_id: final_shipment_scheduled
|
||||||
|
label: "The Final Shipment Is Scheduled"
|
||||||
|
description: >
|
||||||
|
The ring schedules the last major drop — after this, they go dark.
|
||||||
|
This is the closing window. If the detective hasn't uncovered enough
|
||||||
|
by the time this fires, the ring disperses and the operation closes
|
||||||
|
without exposure (escaped outcome). If they have, confrontation
|
||||||
|
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
|
||||||
|
want to participate. Voss insists.
|
||||||
|
triggers:
|
||||||
|
- type: flag_set
|
||||||
|
flag: timeline_accelerating
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: maret_increases_presence
|
||||||
|
ticks: 800
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.final_shipment_scheduled"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: direct notification from Voss.
|
||||||
|
Detective: cargo manifest shows an unusual large entry for 3 days out.
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's schedule changes: he's assigned to the cargo bay
|
||||||
|
during the drop window. He doesn't want to be there.
|
||||||
|
sets_flag: final_shipment_known
|
||||||
|
|
||||||
|
pools:
|
||||||
|
|
||||||
|
# POOL: Ambient ring activity — opportunistic events that add texture
|
||||||
|
- pool_id: ambient_ring_activity
|
||||||
|
label: "Ambient Ring Activity"
|
||||||
|
description: >
|
||||||
|
Low-level ring business that happens throughout the module regardless of
|
||||||
|
player engagement. Creates the sense that the ring exists independently.
|
||||||
|
Players who look closely will find more; players who don't still feel the world moving.
|
||||||
|
events:
|
||||||
|
- event_id: cargo_discrepancy_appears
|
||||||
|
label: "Small Cargo Discrepancy Appears"
|
||||||
|
description: >
|
||||||
|
A minor manifest irregularity appears in The Terminal records.
|
||||||
|
Small enough to be deniable. Systematic enough to be a pattern.
|
||||||
|
The detective's analytical lattice may flag it. The smuggler can
|
||||||
|
correct it if they notice it — covering tracks is part of their role.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 150 # Fires early and repeats
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.cargo_discrepancy_pattern"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Detective: analytical lattice flags during Terminal walkthrough.
|
||||||
|
Smuggler: check manifest terminals (or get flagged by the discrepancy
|
||||||
|
in their own work).
|
||||||
|
once: false # Repeats — pattern builds over time
|
||||||
|
|
||||||
|
- event_id: sera_avoids_torek
|
||||||
|
label: "Sera Avoids Torek at The Bar"
|
||||||
|
description: >
|
||||||
|
Sera Venn reroutes her usual path through The Last Shift to avoid
|
||||||
|
standing near Torek Lintar (the Commission enforcement officer).
|
||||||
|
Anyone watching Sera's normal pattern would notice.
|
||||||
|
This is the detective's first clue that Sera's behavior is odd.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 500
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: evidence-holder
|
||||||
|
description: >
|
||||||
|
Sera takes a longer route to her usual seat, passing through
|
||||||
|
the back of the bar to avoid Torek's sightline.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.sera_avoidance_behavior"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Observe Sera's path through the bar on two separate occasions.
|
||||||
|
Requires forward vision cone and awareness of her baseline route.
|
||||||
|
once: false
|
||||||
|
|
||||||
|
- event_id: nils_makes_supply_run
|
||||||
|
label: "Nils Makes an Unscheduled Supply Run"
|
||||||
|
description: >
|
||||||
|
The ring-operative (Nils) enters the maintenance corridors with a
|
||||||
|
small container logged as "calibration tools". The container isn't
|
||||||
|
logged for return. Someone paying attention to cargo flow would notice.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 700
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-operative
|
||||||
|
description: "Nils takes a container to maintenance corridor C-7."
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.nils_unlogged_cargo"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Watch Nils's cargo handling pattern over two shifts.
|
||||||
|
Or examine maintenance corridor C-7 access log.
|
||||||
|
once: false
|
||||||
|
|
||||||
|
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
|
||||||
|
# Five resolution states. Checked each tick after the first sequence step fires.
|
||||||
|
# Order matters — the storyteller applies the first matching outcome.
|
||||||
|
# is_terminal: true ends the module.
|
||||||
|
|
||||||
|
outcomes:
|
||||||
|
|
||||||
|
# 1. RING EXPOSED
|
||||||
|
# Detective successfully uncovers the operation.
|
||||||
|
# Commission becomes involved. Arrests/flight follow.
|
||||||
|
- outcome_id: ring_exposed
|
||||||
|
label: "Ring Exposed"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The detective accumulates enough evidence to trigger a formal Commission
|
||||||
|
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
|
||||||
|
Kael's situation is now public. The smuggler (if played) faces consequences.
|
||||||
|
Naia learns what Kael was doing — and why he was trying to leave.
|
||||||
|
No clean endings. The right outcome for the detective who goes all the way.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern"
|
||||||
|
- "ring.kael_unauthorized_corridor_access"
|
||||||
|
- "ring.voss_kael_private_meeting"
|
||||||
|
flags_set:
|
||||||
|
- secret_meeting_occurred # set by kael_secret_meeting event
|
||||||
|
- commission_flag_exists # Commission was watching before exposure
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-leader
|
||||||
|
shift: hostile
|
||||||
|
description: "Voss is detained or flees. Commission inquiry opens."
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
shift: hostile
|
||||||
|
description: >
|
||||||
|
Kael is arrested or disappears. His exit attempt is now moot.
|
||||||
|
His relationship with Naia is exposed.
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: lattice-commission
|
||||||
|
reaction: grateful
|
||||||
|
description: "Commission credits the detective's investigation."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss leaves the district — detained, fled, or both."
|
||||||
|
|
||||||
|
# 2. KAEL ESCAPES THE RING
|
||||||
|
# Unique path. Requires the player to engage with Kael directly
|
||||||
|
# and choose to help him rather than expose the ring wholesale.
|
||||||
|
- outcome_id: kael_escapes
|
||||||
|
label: "Kael Escapes the Ring"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
Through the player's choices — helping Kael cover his exit, or warning him,
|
||||||
|
or simply choosing not to act on what they know — Kael successfully leaves
|
||||||
|
the ring before the final shipment. He and Naia leave the district quietly.
|
||||||
|
The ring continues without him, smaller and more cautious.
|
||||||
|
This outcome requires discovering Kael's secret AND choosing restraint.
|
||||||
|
The smuggler can engineer this by covering for Kael with Voss.
|
||||||
|
The detective can achieve this by confronting Kael privately rather than
|
||||||
|
filing a report. The most morally complicated path.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.kael_unauthorized_corridor_access"
|
||||||
|
flags_set:
|
||||||
|
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
|
||||||
|
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
|
||||||
|
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
|
||||||
|
# ring_exposed is checked first in the outcomes list and is terminal,
|
||||||
|
# so kael_escapes only evaluates if ring_exposed hasn't fired.
|
||||||
|
# No flags_not_set needed here — outcome ordering handles priority.
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
shift: friendly
|
||||||
|
description: "Kael remembers whoever helped him. He's gone, but grateful."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: "Kael and Naia leave Sova Transit District."
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: suspicious
|
||||||
|
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
|
||||||
|
|
||||||
|
# 3. RING COMPLETES OPERATION
|
||||||
|
# The ring finishes the final shipment and goes dark before discovery.
|
||||||
|
# Default path if the detective doesn't move fast enough.
|
||||||
|
- outcome_id: ring_completes
|
||||||
|
label: "Ring Completes the Operation"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
|
||||||
|
Kael stays — he's now out by default, the ring having dissolved around him.
|
||||||
|
The evidence trail goes cold. The detective closes the case as inconclusive.
|
||||||
|
The smuggler completes their last run and waits to see if there's another.
|
||||||
|
Unsatisfying only if you expected a tidy resolution. The world moved on.
|
||||||
|
conditions:
|
||||||
|
flags_set:
|
||||||
|
- final_shipment_known # set by final_shipment_scheduled event
|
||||||
|
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
|
||||||
|
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
|
||||||
|
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
|
||||||
|
# making this outcome permanently unreachable. Replaced with player-action fact gate.
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: neutral
|
||||||
|
description: "The ring successfully completed this operation. They'll be back."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss transfers to another station for 'career development'."
|
||||||
|
- type: location_access_change
|
||||||
|
location: maintenance-corridors
|
||||||
|
change: open
|
||||||
|
description: "The restricted supply closet is now empty. Access log shows it cleared."
|
||||||
|
|
||||||
|
# 4. RING SPLINTERS
|
||||||
|
# Partial discovery. The ring fractures but doesn't fully collapse.
|
||||||
|
# An incomplete ending that leaves threads for future investigation.
|
||||||
|
- outcome_id: ring_splinters
|
||||||
|
label: "Ring Splinters"
|
||||||
|
is_terminal: false # Not terminal — splinter state can evolve
|
||||||
|
description: >
|
||||||
|
Enough evidence surfaces that the ring knows it's been partially seen.
|
||||||
|
Voss shuts down active operations. Nils disappears. Kael stays — now the
|
||||||
|
one person in the district who knows what happened and has no one to tell.
|
||||||
|
The formal investigation stalls for lack of a clear chain of evidence.
|
||||||
|
The detective has facts but not the complete picture. The smuggler
|
||||||
|
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
|
||||||
|
events_fired:
|
||||||
|
- kael_goes_cold # event ID — Kael's behavioral shift fired
|
||||||
|
flags_set:
|
||||||
|
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
|
||||||
|
ticks_since_activation: 2400
|
||||||
|
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
|
||||||
|
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-leader
|
||||||
|
shift: suspicious
|
||||||
|
description: "Voss goes quiet. He's watching to see who knows what."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-operative
|
||||||
|
description: "Nils stops appearing at The Terminal. Transferred, officially."
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: suspicious
|
||||||
|
description: "The ring is alerted to exposure risk. Future operations will be more careful."
|
||||||
|
|
||||||
|
# 5. INVESTIGATION STALLS (post-splinter exit)
|
||||||
|
# The ring splinters but the detective never breaks through to the pivot evidence.
|
||||||
|
# Explicit terminal exit for the non-terminal ring_splinters state.
|
||||||
|
- outcome_id: ring_stalemate
|
||||||
|
label: "Investigation Stalls"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The ring went dark after the splinter. The detective has the cargo discrepancy
|
||||||
|
on record — enough to flag, not enough to pursue. The case stays open but cold.
|
||||||
|
No arrests. No answers. Kael stays in the district, the only person who knows
|
||||||
|
the full shape of what happened, with no one left to tell it to.
|
||||||
|
The ring will reconstitute elsewhere. It always does.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
|
||||||
|
flags_set:
|
||||||
|
- kael_missed_drop
|
||||||
|
- final_shipment_known # ring finished while investigation stalled
|
||||||
|
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: lattice-commission
|
||||||
|
reaction: neutral
|
||||||
|
description: "The discrepancy flag stays in Maret's file. No follow-up action."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss quietly transfers. No announcement, no incident report."
|
||||||
|
|
||||||
|
# 6. MODULE EXPIRY (quiet exit)
|
||||||
|
# Player never engaged at all. Module times out without drama.
|
||||||
|
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
|
||||||
|
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
|
||||||
|
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
|
||||||
|
# Gate expiry on player-action-required facts instead.
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
label: "Module Abandoned"
|
||||||
|
is_terminal: true
|
||||||
|
is_expiry: true
|
||||||
|
description: >
|
||||||
|
The player never engaged with the ring's signals. The final shipment
|
||||||
|
completed without incident. The ring disperses on its own schedule.
|
||||||
|
Kael stays. The world is unchanged. This is not failure — it's the game
|
||||||
|
acknowledging that not every conspiracy needs a protagonist.
|
||||||
|
The 70% mundane majority (D-029) plays out: life continued.
|
||||||
|
conditions:
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
|
||||||
|
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: neutral
|
||||||
|
description: "The ring closed operations without incident. No record of compromise."
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
# Drama Module Schema — Tier 1 Content (D-023)
|
||||||
|
# YAML expression of JSON Schema 2020-12
|
||||||
|
# Validated against this schema: content/modules/tier1/*.yaml
|
||||||
|
#
|
||||||
|
# Ownership:
|
||||||
|
# Dramatic structure (this file): Paula
|
||||||
|
# YAML validation tooling / serde structs: Gestalt / Tyre
|
||||||
|
# Authoring ergonomics review: Mellanie
|
||||||
|
#
|
||||||
|
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
|
||||||
|
|
||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||||
|
$id: "drama_module.schema.yaml"
|
||||||
|
title: "Tier 1 Drama Module"
|
||||||
|
description: >
|
||||||
|
A hand-authored drama module drawn from the pool at game start.
|
||||||
|
The storyteller activates one or more modules per playthrough based on
|
||||||
|
entry conditions, then fires events and detects outcomes. Tier 1 modules
|
||||||
|
are the conspiracy layer of D-023 — authored, optional, relocatable.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- module_id
|
||||||
|
- display_name
|
||||||
|
- version
|
||||||
|
- tier
|
||||||
|
- pool
|
||||||
|
- entry_conditions
|
||||||
|
- npc_requirements
|
||||||
|
- events
|
||||||
|
- outcomes
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
properties:
|
||||||
|
|
||||||
|
# ── IDENTITY ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
module_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||||
|
description: >
|
||||||
|
Stable unique slug. Format: {name}_v{major}_{minor}.
|
||||||
|
Never reuse IDs. Increment version on breaking structural changes.
|
||||||
|
Example: "smuggling_ring_v0_1"
|
||||||
|
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: "Human-readable title shown in dev/debug tooling."
|
||||||
|
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
pattern: "^[0-9]+\\.[0-9]+$"
|
||||||
|
description: "Authoring version. Semantic: major.minor."
|
||||||
|
|
||||||
|
tier:
|
||||||
|
type: integer
|
||||||
|
const: 1
|
||||||
|
description: "Always 1 for Tier 1 drama modules."
|
||||||
|
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "One-paragraph authoring summary. Not shown in-game."
|
||||||
|
|
||||||
|
# ── POOL METADATA ─────────────────────────────────────────────────────────
|
||||||
|
# Controls how the storyteller includes this module in the per-playthrough pool.
|
||||||
|
|
||||||
|
pool:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- weight
|
||||||
|
additionalProperties: false
|
||||||
|
description: "How the storyteller samples this module from the pool."
|
||||||
|
properties:
|
||||||
|
weight:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 10
|
||||||
|
description: >
|
||||||
|
Relative selection probability (1–10). Higher = more likely to be
|
||||||
|
included in a given playthrough's active module set. Default: 5.
|
||||||
|
compatible_districts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
District slugs where this module can activate, or omit for "any".
|
||||||
|
Example: ["sova-transit"]
|
||||||
|
incompatible_with:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||||
|
description: >
|
||||||
|
Module IDs that cannot run concurrently with this one.
|
||||||
|
The storyteller will not activate both in the same playthrough.
|
||||||
|
max_concurrent:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
description: >
|
||||||
|
Maximum simultaneous active instances. Almost always 1.
|
||||||
|
Set to 2+ only for modules designed to stack (rare).
|
||||||
|
|
||||||
|
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
|
||||||
|
# All listed conditions must be true for the module to become activatable.
|
||||||
|
# The storyteller checks these each tick after min_play_ticks.
|
||||||
|
|
||||||
|
entry_conditions:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- activation
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
World-state prerequisites. The storyteller activates the module when
|
||||||
|
ALL conditions are satisfied AND the activation trigger fires.
|
||||||
|
properties:
|
||||||
|
world_state:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/world_state_condition"
|
||||||
|
description: "World-state conditions checked each tick."
|
||||||
|
player:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/player_condition"
|
||||||
|
description: >
|
||||||
|
Optional player-state conditions. Module can activate without
|
||||||
|
player engagement — these gate on player-specific world state,
|
||||||
|
not on player noticing the module.
|
||||||
|
activation:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- trigger
|
||||||
|
additionalProperties: false
|
||||||
|
description: "How and when activation is evaluated."
|
||||||
|
properties:
|
||||||
|
trigger:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- proximity # Player comes within range of a key NPC/location
|
||||||
|
- storyteller_push # Storyteller activates on its own schedule
|
||||||
|
- player_action # Player performs a specific action
|
||||||
|
description: "What pushes the module from 'eligible' to 'active'."
|
||||||
|
min_play_ticks:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: >
|
||||||
|
Minimum ticks of game time before this module can activate.
|
||||||
|
Enforces D-027 success criterion #1: 30 minutes of daily-life
|
||||||
|
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
|
||||||
|
proximity_location:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Required when trigger = proximity. Location slug the player
|
||||||
|
must enter or approach. Example: "maintenance-corridors"
|
||||||
|
proximity_radius_tiles:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
description: >
|
||||||
|
Required when trigger = proximity. Tile radius around the
|
||||||
|
location's anchor point.
|
||||||
|
player_action_required:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Required when trigger = player_action. The action that fires
|
||||||
|
activation. Example: "examine:cargo-manifest"
|
||||||
|
|
||||||
|
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
|
||||||
|
# NPC slots this module requires. Each slot is filled at module load time.
|
||||||
|
# Named bindings resolve to specific authored NPCs; generated bindings
|
||||||
|
# are filled from the district's generated NPC pool.
|
||||||
|
|
||||||
|
npc_requirements:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/npc_slot"
|
||||||
|
description: >
|
||||||
|
Module-internal NPC role slots. Roles are referenced by slug throughout
|
||||||
|
the rest of this document. Hand-authored NPCs use named bindings.
|
||||||
|
Generated NPCs use constraint-based bindings.
|
||||||
|
|
||||||
|
# ── EVENTS ────────────────────────────────────────────────────────────────
|
||||||
|
# Ordered sequences and unordered event pools the storyteller can fire.
|
||||||
|
# Sequences are narrative beats in a defined order.
|
||||||
|
# Pools are events the storyteller can fire in any order when conditions are met.
|
||||||
|
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "Event sequences and pools the storyteller manages."
|
||||||
|
properties:
|
||||||
|
sequences:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_sequence"
|
||||||
|
description: >
|
||||||
|
Ordered event sequences. Steps fire in order; the next step
|
||||||
|
becomes eligible only after the previous one fires.
|
||||||
|
pools:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_pool"
|
||||||
|
description: >
|
||||||
|
Unordered event pools. The storyteller may fire any eligible
|
||||||
|
event in the pool when its trigger conditions are met.
|
||||||
|
|
||||||
|
# ── OUTCOMES ──────────────────────────────────────────────────────────────
|
||||||
|
# Resolution states the module can reach. The storyteller checks outcome
|
||||||
|
# conditions each tick. First matching outcome wins.
|
||||||
|
# Every module MUST include an expiry outcome.
|
||||||
|
|
||||||
|
outcomes:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/outcome"
|
||||||
|
description: >
|
||||||
|
Terminal and transitional resolution states. The storyteller checks
|
||||||
|
these each tick and applies the first matching outcome.
|
||||||
|
|
||||||
|
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
|
||||||
|
|
||||||
|
dual_lens:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "Authoring-only. How smuggler vs detective experience this module."
|
||||||
|
properties:
|
||||||
|
smuggler: { type: string }
|
||||||
|
detective: { type: string }
|
||||||
|
|
||||||
|
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$defs:
|
||||||
|
|
||||||
|
# World-state condition types
|
||||||
|
|
||||||
|
world_state_condition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A single world-state prerequisite for module activation."
|
||||||
|
oneOf:
|
||||||
|
- # NPC with the given module role is present in the district
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_present" }
|
||||||
|
role: { type: string, description: "Module-internal NPC role slug." }
|
||||||
|
required: [type, role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A specific location is accessible to the player
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_accessible" }
|
||||||
|
location: { type: string, description: "Location slug." }
|
||||||
|
required: [type, location]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player has NOT yet discovered a specific fact
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_not_known" }
|
||||||
|
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # No other Tier 1 module of the given ID is currently active
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "no_active_module" }
|
||||||
|
module_id: { type: string }
|
||||||
|
required: [type, module_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A named fact IS known (module requires precondition awareness)
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_known" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
known_by: { type: string, enum: [smuggler, detective, any] }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Player-state condition types
|
||||||
|
|
||||||
|
player_condition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A player-state prerequisite."
|
||||||
|
oneOf:
|
||||||
|
- # Player has reached minimum relationship threshold with an NPC
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "relationship_threshold" }
|
||||||
|
npc_role: { type: string, description: "Module-internal NPC role." }
|
||||||
|
min_state:
|
||||||
|
type: string
|
||||||
|
enum: [stranger, known, friendly]
|
||||||
|
description: "Minimum RelationshipState required."
|
||||||
|
required: [type, npc_role, min_state]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Minimum game ticks elapsed
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "min_ticks" }
|
||||||
|
ticks: { type: integer, minimum: 0 }
|
||||||
|
required: [type, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# NPC slot definition
|
||||||
|
|
||||||
|
npc_slot:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- role
|
||||||
|
- binding
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
One NPC slot in the module. Named binding = specific authored NPC.
|
||||||
|
Generated binding = constraint-matched NPC from district pool.
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*$"
|
||||||
|
description: >
|
||||||
|
Module-internal role slug. Referenced in events, outcomes, and
|
||||||
|
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
|
||||||
|
display_hint:
|
||||||
|
type: string
|
||||||
|
description: "Authoring note. What this role is narratively."
|
||||||
|
binding:
|
||||||
|
type: string
|
||||||
|
enum: [named, generated]
|
||||||
|
description: >
|
||||||
|
named = resolves to a specific authored NPC (use named_npc).
|
||||||
|
generated = any district NPC matching the axis constraints.
|
||||||
|
named_npc:
|
||||||
|
type: string
|
||||||
|
pattern: "^npc:[a-z][a-z0-9-]*$"
|
||||||
|
description: >
|
||||||
|
Required when binding = named. Short-form NPC canonical ID.
|
||||||
|
Example: "npc:kael-davan"
|
||||||
|
axes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/axis_constraint"
|
||||||
|
description: >
|
||||||
|
Required when binding = generated. The NPC must satisfy all
|
||||||
|
listed axis constraints to fill this slot.
|
||||||
|
must_have_pattern:
|
||||||
|
type: string
|
||||||
|
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
|
||||||
|
description: "Optional: NPC must have this pattern (D-024)."
|
||||||
|
must_have_motivation:
|
||||||
|
type: string
|
||||||
|
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
|
||||||
|
description: "Optional: NPC must have this motivation (D-024)."
|
||||||
|
is_optional:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: >
|
||||||
|
If true, the module can activate without this slot filled.
|
||||||
|
Optional slots produce degraded but valid module runs.
|
||||||
|
|
||||||
|
# NPC axis constraint (used in generated bindings)
|
||||||
|
|
||||||
|
axis_constraint:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- axis
|
||||||
|
- constraint
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
axis:
|
||||||
|
type: string
|
||||||
|
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
|
||||||
|
description: "Which NPC axis to constrain (D-024)."
|
||||||
|
constraint:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Constraint expression. Freeform string interpreted by the storyteller.
|
||||||
|
Convention: "has_{value}", "min_{N}", "not_{value}".
|
||||||
|
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
|
||||||
|
|
||||||
|
# Event sequence
|
||||||
|
|
||||||
|
event_sequence:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- sequence_id
|
||||||
|
- steps
|
||||||
|
additionalProperties: false
|
||||||
|
description: "An ordered sequence of narrative events."
|
||||||
|
properties:
|
||||||
|
sequence_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
steps:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_step"
|
||||||
|
|
||||||
|
# Unordered event pool
|
||||||
|
|
||||||
|
event_pool:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- pool_id
|
||||||
|
- events
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
pool_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_step"
|
||||||
|
|
||||||
|
# Individual event step
|
||||||
|
|
||||||
|
event_step:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- event_id
|
||||||
|
- triggers
|
||||||
|
additionalProperties: false
|
||||||
|
description: "A single storyteller-managed event with triggers and effects."
|
||||||
|
properties:
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
description: "Unique within this module. Used in outcome conditions."
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "What happens narratively when this event fires."
|
||||||
|
triggers:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_trigger"
|
||||||
|
description: "ANY trigger being true fires this event."
|
||||||
|
effects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_effect"
|
||||||
|
description: "What changes in the world when this event fires."
|
||||||
|
once:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: "If true, fires only once. If false, may repeat when conditions reset."
|
||||||
|
sets_flag:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
description: "Module-internal flag set when this event fires. Queryable in outcomes."
|
||||||
|
|
||||||
|
# Event trigger conditions
|
||||||
|
|
||||||
|
event_trigger:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A condition that causes an event to fire."
|
||||||
|
oneOf:
|
||||||
|
- # Ticks elapsed since module activation
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "ticks_since_activation" }
|
||||||
|
ticks: { type: integer, minimum: 1 }
|
||||||
|
required: [type, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Ticks elapsed since a previous event fired
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "ticks_since_event" }
|
||||||
|
after_event: { type: string }
|
||||||
|
ticks: { type: integer, minimum: 1 }
|
||||||
|
required: [type, after_event, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player enters a location or comes within range of NPC
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "player_proximity" }
|
||||||
|
target_type: { type: string, enum: [location, npc_role] }
|
||||||
|
target: { type: string }
|
||||||
|
radius_tiles: { type: integer, minimum: 1 }
|
||||||
|
required: [type, target_type, target]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player performs an interaction
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "player_action" }
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [talk, examine, confront, follow, observe]
|
||||||
|
target_role: { type: string, description: "Module NPC role or location slug." }
|
||||||
|
required: [type, action, target_role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player has discovered a specific fact
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_known_by_player" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A module flag has been set
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "flag_set" }
|
||||||
|
flag: { type: string }
|
||||||
|
required: [type, flag]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC enters a specific mood state
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_mood" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
mood:
|
||||||
|
type: string
|
||||||
|
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
|
||||||
|
required: [type, npc_role, mood]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Event effects
|
||||||
|
|
||||||
|
event_effect:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A world change triggered by an event."
|
||||||
|
oneOf:
|
||||||
|
- # NPC deviates from their normal routine
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_routine_deviation" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string, description: "What the deviation looks like." }
|
||||||
|
duration_ticks: { type: integer }
|
||||||
|
required: [type, npc_role, description]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A fact becomes discoverable (moves to Rumoured confidence)
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_becomes_discoverable" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
discoverable_by:
|
||||||
|
type: string
|
||||||
|
enum: [smuggler, detective, any]
|
||||||
|
discovery_method:
|
||||||
|
type: string
|
||||||
|
description: "How the player can discover this. Authoring note."
|
||||||
|
required: [type, fact_id, discoverable_by]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC tell behavior becomes more pronounced
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "tell_intensify" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A module-internal flag is set
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "flag_set" }
|
||||||
|
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
|
||||||
|
required: [type, flag]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Something changes about a location
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_state" }
|
||||||
|
location: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, location, description]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC's access to information changes
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_knowledge_update" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
fact_id: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Module outcome definition
|
||||||
|
|
||||||
|
outcome:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- outcome_id
|
||||||
|
- label
|
||||||
|
- is_terminal
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
A resolution state the module can reach. Conditions are checked each tick.
|
||||||
|
The first matching outcome is applied. is_terminal = true ends the module.
|
||||||
|
properties:
|
||||||
|
outcome_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "What this outcome means narratively."
|
||||||
|
is_terminal:
|
||||||
|
type: boolean
|
||||||
|
description: "If true, this outcome ends the module permanently."
|
||||||
|
is_expiry:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: >
|
||||||
|
If true, this is the quiet-exit outcome when the player never engages.
|
||||||
|
Every module must include exactly one expiry outcome.
|
||||||
|
conditions:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "ALL conditions must be true to reach this outcome."
|
||||||
|
properties:
|
||||||
|
facts_known:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "Player must know all these facts."
|
||||||
|
facts_not_known:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "Player must NOT know any of these facts."
|
||||||
|
flags_set:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "All these module flags must be set."
|
||||||
|
flags_not_set:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "None of these module flags may be set."
|
||||||
|
events_fired:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "All these events must have fired."
|
||||||
|
ticks_since_activation:
|
||||||
|
type: integer
|
||||||
|
description: "Module has been active for at least this many ticks."
|
||||||
|
effects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/outcome_effect"
|
||||||
|
description: "Effects applied when this outcome is reached."
|
||||||
|
|
||||||
|
# Outcome-level effects (broader scope than event effects)
|
||||||
|
|
||||||
|
outcome_effect:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
oneOf:
|
||||||
|
- # NPC disposition toward player changes
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_disposition" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
shift:
|
||||||
|
type: string
|
||||||
|
enum: [hostile, suspicious, neutral, friendly]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role, shift]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Faction reaction
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "faction_reaction" }
|
||||||
|
faction: { type: string }
|
||||||
|
reaction:
|
||||||
|
type: string
|
||||||
|
enum: [hostile, suspicious, neutral, friendly, grateful]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, faction, reaction]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Location becomes restricted or opens up
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_access_change" }
|
||||||
|
location: { type: string }
|
||||||
|
change:
|
||||||
|
type: string
|
||||||
|
enum: [restricted, locked, open]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, location, change]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A fact is now permanently known/unknown
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_state" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum: [known, hidden, destroyed]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, fact_id, state]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC leaves the district or changes role
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_exit" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role]
|
||||||
|
additionalProperties: false
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Decision ID management — claim, query, and validate decision IDs.
|
||||||
|
# Usage:
|
||||||
|
# decision next [D|Q|R] Show next available ID
|
||||||
|
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
|
||||||
|
# decision check-dupes Check for duplicate IDs in markdown
|
||||||
|
# decision sync Sync markdown -> DB
|
||||||
|
exec python3 "$(dirname "$0")/decisions_sync.py" "$@"
|
||||||
@@ -366,22 +366,133 @@ def sync(cfg):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ID claiming — database is authority for ID allocation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def next_id(cfg, prefix=None):
|
||||||
|
"""Return the next available ID for a given prefix (D, Q, R) or all."""
|
||||||
|
conn = get_connection(cfg)
|
||||||
|
try:
|
||||||
|
result = {}
|
||||||
|
prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"]
|
||||||
|
for p in prefixes:
|
||||||
|
# Check both DB and markdown files for the highest ID
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num "
|
||||||
|
"FROM decisions WHERE id LIKE ?",
|
||||||
|
(f"{p}-%",),
|
||||||
|
).fetchone()
|
||||||
|
db_max = row["max_num"] if row and row["max_num"] else 0
|
||||||
|
|
||||||
|
# Also scan markdown files in case they're ahead of the DB
|
||||||
|
md_max = 0
|
||||||
|
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||||
|
if filepath.name.lower() == "readme.md":
|
||||||
|
continue
|
||||||
|
text = filepath.read_text(encoding="utf-8")
|
||||||
|
for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE):
|
||||||
|
num = int(m.group(1))
|
||||||
|
if num > md_max:
|
||||||
|
md_max = num
|
||||||
|
|
||||||
|
highest = max(db_max, md_max)
|
||||||
|
next_num = highest + 1
|
||||||
|
result[p] = f"{p}-{next_num:03d}"
|
||||||
|
|
||||||
|
return {"ok": True, **result}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def claim_id(cfg, prefix, domain, title):
|
||||||
|
"""Claim the next available ID and insert a placeholder into the DB."""
|
||||||
|
if prefix not in ("D", "Q", "R"):
|
||||||
|
return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."}
|
||||||
|
|
||||||
|
type_map = {"D": "confirmed", "Q": "question", "R": "rejected"}
|
||||||
|
status_map = {"D": "active", "Q": "open", "R": "active"}
|
||||||
|
|
||||||
|
nxt = next_id(cfg, prefix)
|
||||||
|
if not nxt.get("ok"):
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
new_id = nxt[prefix]
|
||||||
|
conn = get_connection(cfg)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
|
||||||
|
(new_id, type_map[prefix], domain, title, status_map[prefix],
|
||||||
|
f"decisions/{domain}.md"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "id": new_id, "domain": domain, "title": title}
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
conn.rollback()
|
||||||
|
return {"ok": False, "error": f"ID conflict: {exc}"}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def check_dupes(cfg):
|
||||||
|
"""Check for duplicate decision IDs across all markdown files."""
|
||||||
|
# Pre-existing collisions too deeply embedded to renumber (139+ references).
|
||||||
|
# New collisions are prevented by the claim workflow.
|
||||||
|
KNOWN_EXCEPTIONS = {"D-035"}
|
||||||
|
|
||||||
|
id_locations = {} # id -> [(file, line_number)]
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||||
|
if filepath.name.lower() == "readme.md":
|
||||||
|
continue
|
||||||
|
text = filepath.read_text(encoding="utf-8")
|
||||||
|
for i, line in enumerate(text.split("\n"), 1):
|
||||||
|
m = HEADING_RE.match(line)
|
||||||
|
if m:
|
||||||
|
did = m.group(1)
|
||||||
|
if did not in id_locations:
|
||||||
|
id_locations[did] = []
|
||||||
|
id_locations[did].append((filepath.name, i))
|
||||||
|
|
||||||
|
dupes = {did: locs for did, locs in id_locations.items()
|
||||||
|
if len(locs) > 1 and did not in KNOWN_EXCEPTIONS}
|
||||||
|
|
||||||
|
if dupes:
|
||||||
|
for did, locs in sorted(dupes.items()):
|
||||||
|
loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs)
|
||||||
|
warnings.append(f"DUPLICATE {did}: {loc_str}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": len(dupes) == 0,
|
||||||
|
"total_ids": len(id_locations),
|
||||||
|
"duplicates": len(dupes),
|
||||||
|
"known_exceptions": list(KNOWN_EXCEPTIONS),
|
||||||
|
"details": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
HELP_TEXT = """\
|
HELP_TEXT = """\
|
||||||
Commonwealth Decisions Sync
|
Commonwealth Decisions Sync & ID Management
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
||||||
decisions_sync.py --help Show this help message
|
decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one)
|
||||||
|
decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder
|
||||||
|
decisions_sync.py check-dupes Check for duplicate IDs across markdown files
|
||||||
|
decisions_sync.py --help Show this help message
|
||||||
|
|
||||||
Parses all markdown files in decisions/ (excluding README.md), extracts
|
ID claiming workflow:
|
||||||
decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions
|
1. Agent calls 'claim D architecture "Per-game save dirs"'
|
||||||
and decision_refs tables.
|
2. Gets back D-085 (or whatever is next)
|
||||||
|
3. Agent writes D-085 in the appropriate domain file
|
||||||
Idempotent: safe to run repeatedly. References are rebuilt on every sync.
|
4. Pre-commit hook runs check-dupes to catch collisions
|
||||||
|
|
||||||
Config: {config}
|
Config: {config}
|
||||||
Schema: {schema}
|
Schema: {schema}
|
||||||
@@ -404,6 +515,18 @@ def main():
|
|||||||
|
|
||||||
if cmd == "sync":
|
if cmd == "sync":
|
||||||
result = sync(cfg)
|
result = sync(cfg)
|
||||||
|
elif cmd == "next":
|
||||||
|
result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None)
|
||||||
|
elif cmd == "claim":
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
result = {"ok": False, "error": "Usage: claim <D|Q|R> <domain> [title]"}
|
||||||
|
else:
|
||||||
|
prefix = sys.argv[2].upper()
|
||||||
|
domain = sys.argv[3]
|
||||||
|
title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)"
|
||||||
|
result = claim_id(cfg, prefix, domain, title)
|
||||||
|
elif cmd == "check-dupes":
|
||||||
|
result = check_dupes(cfg)
|
||||||
else:
|
else:
|
||||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
|||||||
|
|
||||||
| File | Domain | Decisions |
|
| File | Domain | Decisions |
|
||||||
|------|--------|-----------|
|
|------|--------|-----------|
|
||||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 |
|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088 |
|
||||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
|
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
|
||||||
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 |
|
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092 |
|
||||||
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 |
|
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091 |
|
||||||
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||||
| [questions.md](questions.md) | Open questions | Q-001 through Q-026 |
|
| [questions.md](questions.md) | Open questions | Q-001 through Q-039 |
|
||||||
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
||||||
|
|
||||||
## Querying Decisions
|
## Querying Decisions
|
||||||
|
|||||||
@@ -210,4 +210,28 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*16 decisions. Last updated: 2026-02-16*
|
### D-085: Per-game save directory structure
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** Every new game creates a dedicated directory under the user save path. All saves for that game (manual, quicksave, autosave) live inside the game's directory. Directory name includes a human-readable game identifier and creation timestamp.
|
||||||
|
- **Rationale:** Natively groups saves by game without requiring a database or index file. Players can browse, back up, or delete game saves at the filesystem level. Avoids a flat save folder where 50+ files from different games are interleaved.
|
||||||
|
- **Structure:** `user://saves/<game-id>/` where `<game-id>` is `<timestamp>-<seed>` (e.g., `20260225-143022-a7b3f1/`). Inside: `quicksave.sav`, `autosave.sav`, `manual_001.sav`, etc.
|
||||||
|
- **Constraints:**
|
||||||
|
- Game directory created on "New Game" — even before the first save, so the path exists for quicksave/autosave.
|
||||||
|
- F5 = quicksave (overwrites `quicksave.sav` in the active game dir).
|
||||||
|
- F6 = quickload (loads `quicksave.sav` from the active game dir).
|
||||||
|
- Loading screen lists game directories sorted by last-modified, shows most recent save per game.
|
||||||
|
- **Raised by:** Team Leader (Jeroen)
|
||||||
|
- **Dissent:** None
|
||||||
|
|
||||||
|
### D-088: 3-state pause system — Normal/Overlay/Paused, server-authoritative
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** Simulation runs at three speed states: Normal (100% tick rate), Overlay (50% — active during knowledge panel, dialogue, map view), Paused (0% — full pause via Esc). Server is authoritative: client sends pause requests, server sets `sim_speed` field in ObserverSnapshot. Client reads `sim_speed` and adjusts presentation. No client-side tick manipulation.
|
||||||
|
- **Rationale:** Server-authoritative speed states preserve D-010 principle 4 (deterministic simulation). Client cannot modify simulation state directly. Overlay mode at 50% ensures UI interactions do not require a hard pause while still giving the player time to read and decide.
|
||||||
|
- **Raised by:** Tyre, Dudley
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** v0.1 Content Scoping Workshop, closing round resolution
|
||||||
|
- **Cross-reference:** D-031 (time system), D-020 (client-server architecture)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*18 decisions. Last updated: 2026-02-12 (D-088 added — retroactive filing from v0.1 Content Scoping Workshop)*
|
||||||
|
|||||||
+44
-1
@@ -182,6 +182,49 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
|||||||
- **Raised by:** Tyre (technical analysis, architecture synthesis)
|
- **Raised by:** Tyre (technical analysis, architecture synthesis)
|
||||||
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
|
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
|
||||||
|
|
||||||
|
### D-084: Dual-namespace line ID scheme — role pool + instance override
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** Line IDs for auto-generated NPC content use a dual-namespace approach that eliminates the Q-028 collision problem without altering the existing ID format.
|
||||||
|
- **Role namespace (unchanged, primary):** `{role-slug}_{d|m|e}_{###}` — e.g., `dock-worker_d_001`. These are shared lines delivered by any instance of the role. One file, one ID sequence per role-at-location. No per-instance authoring, no collision possible. All existing authored content is unaffected.
|
||||||
|
- **Instance namespace (new, opt-in):** `{role-slug}-{zero-padded counter}_{d|m|e}_{###}` — e.g., `dock-worker-07_d_001`. Counter is the generation-order rank within the role group for that district, starting at 01, assigned deterministically from the world seed. Used only when a specific generated NPC needs authored content that differs from the role pool.
|
||||||
|
- **Key design choices:**
|
||||||
|
- **Why the collision problem is mostly already solved:** The Q-028 collision framing assumed NPC-scoped IDs require per-instance ID sequences. They don't. D-028 tagged line pools are role-scoped: `dock-worker_d_001` is content that any dock worker can deliver. Forty dock workers all drawing from `dock-worker_d_###` is correct behavior, not a collision. A collision would only occur if two *distinct authored lines* shared the same ID — which the role namespace prevents by definition (one file, one sequence).
|
||||||
|
- **Instance namespace scope:** Opt-in only. Tier 3 (flat wallpaper) and Tier 2 (mundane triangles) auto-generated NPCs use the role pool exclusively. Instance pools are authored only when a specific generated NPC needs content variation the role pool cannot supply (e.g., a generated NPC flagged as a triangle member with unique tell lines).
|
||||||
|
- **History log disambiguation:** The speaker of a line is identified by `StableId`, not by line ID. Line ID identifies content; `StableId` identifies the speaker. `(StableId: 42, line_id: "dock-worker_d_001")` and `(StableId: 43, line_id: "dock-worker_d_001")` are two different log entries for the same content line — no collision in the log.
|
||||||
|
- **Counter stability:** Generation order within a role group is seeded from the world seed. Same seed → same order → same counter assignments. Counter is recorded in the district's NPC roster at world-gen time. The counter survives save/load because it is part of the generated NPC's profile, not recomputed at runtime.
|
||||||
|
- **Schema compatibility:** The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` already accepts `dock-worker-07_d_001`. No regex change required. No content migration required.
|
||||||
|
- **Rejected alternatives:**
|
||||||
|
- **StableId prefix (`npc-00042_d_001`):** StableId is assigned at load time from sorted canonical IDs. Authors cannot know it before writing files. A generate-then-bake pipeline would break the content/generation separation principle. Rejected.
|
||||||
|
- **UUID suffix (`dock-worker-a3f2_d_001`):** UUIDs are stable per seed but change across seeds, orphaning any authored instance content on replay. Rejected.
|
||||||
|
- **Slug registry with collision resolution (`dock-worker`, `dock-worker-2`, ...):** First instance gets a privileged non-suffixed slug while all others get a counter, creating asymmetry with no upside. Rejected.
|
||||||
|
- **Implementation requirement:** The content registry (`server/src/knowledge/registry.rs` or a new `server/src/content/npc_slug.rs`) tracks a `RoleCounter: BTreeMap<String, u32>` per district. Incremented when a generated NPC claims an instance namespace slot. Stored in the district manifest. Provides `generate_instance_slug(role_slug) -> String` returning `{role-slug}-{counter:02}`.
|
||||||
|
- **Hand-authored NPCs:** Unchanged. `kael-davan`, `sera-venn`, and all named authored NPCs keep their current slugs and ID sequences. No migration.
|
||||||
|
- **Resolves:** Q-028
|
||||||
|
- **Cross-reference:** Line ID scheme ([D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools)), population model ([D-029](#d-029-population-entanglement-ratio--305020)), NPC generation ([D-024](#d-024-npc-generation-model--10-axes--combat-component))
|
||||||
|
- **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review.
|
||||||
|
- **Dissent:** None.
|
||||||
|
|
||||||
|
### D-090: PC voice registers — smuggler and detective speech patterns
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** Each playable character has a defined voice register for monologue and dialogue:
|
||||||
|
- **Smuggler:** Feeling-first. Sentence fragments. Concrete/physical vocabulary. Notices bodies, spaces, exits. Emotional baseline: wary comfort. Lies by omission. Relationship to authority: avoidance.
|
||||||
|
- **Detective:** Analysis-first. Complete sentences. Institutional vocabulary. Notices patterns, inconsistencies, procedural gaps. Emotional baseline: professional detachment. Lies by reframing. Relationship to authority: representative.
|
||||||
|
These registers govern all authored content per character (monologue pools per D-032, dialogue access per D-028).
|
||||||
|
- **Rationale:** Register differences must be architectural, not incidental. Without defined registers, authors default toward a single generic voice and the dual-lens effect (D-027 criterion 2) collapses. The registers encode the characters' relationships to the world, not just vocabulary preferences.
|
||||||
|
- **Raised by:** Mellanie, Paula
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** v0.1 Content Scoping Workshop, Mellanie Round 2 synthesis
|
||||||
|
- **Cross-reference:** D-032 (separate monologue pools), D-034 (THE FRIEND pattern)
|
||||||
|
|
||||||
|
### D-092: Anchor line requirement in NPC style guide
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** Every NPC at Tier 1 and Tier 2 depth must have anchor lines — signature phrases or verbal tics that make them instantly recognizable in text. Requirements: Tier 1 NPCs (THE FRIEND, key triangle members): minimum 2 anchor lines per arc phase. Tier 2 NPCs (triangle periphery): minimum 1 anchor line. Tier 3 NPCs (background): no anchor requirement, generic pool lines only. Anchor lines must be authored, never generated.
|
||||||
|
- **Rationale:** Anchor lines create the "I know that voice" moment on repeat encounters. Generation cannot produce this — generated lines are statistically average, not distinctively characteristic. The generation expansion pass (D-028) fills volume; anchor lines create identity.
|
||||||
|
- **Raised by:** Mellanie
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** Wiki Review Workshop, Mellanie Round 2 proposal, consensus C-19
|
||||||
|
- **Cross-reference:** D-034 (THE FRIEND pattern), D-028 (dialogue architecture), D-023 (three-tier content model)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*16 decisions. Last updated: 2026-02-19 (D-075 dissent updated)*
|
*19 decisions. Last updated: 2026-02-12 (D-090, D-092 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)*
|
||||||
|
|||||||
+20
-1
@@ -446,6 +446,25 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
|||||||
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
|
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
|
||||||
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
|
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
|
||||||
|
|
||||||
|
### D-086: Insert icon system — custom SVG, no icon font
|
||||||
|
- **Date:** 2026-02-24
|
||||||
|
- **Decision:** The insert overlay (z-layer 6) uses **custom SVG icons**, not an icon font. The v0.1 icon vocabulary (~12–15 shapes: stance indicators, perception mode icons, inventory item silhouettes, border arrows) is too small and too specific for a font. No available icon font (Material Symbols, Phosphor, Feather, Tabler) matches the insert's geometric/diegetic visual language — they all read as "app UI," not "neural lattice overlay." Custom SVGs authored to the insert's constraint set (clean geometry, minimal anchor points, `#c8d0e0` chrome color, 1px stroke base weight) ensure all insert elements feel generated by the same system.
|
||||||
|
- **Why not an icon font:**
|
||||||
|
- **Vocabulary mismatch:** No font has "hexagonal corridor access token" or "thermal perception mode" in a sci-fi register. Inventory silhouettes are universe-specific — we author them regardless.
|
||||||
|
- **Weight precision:** The spec defines 1px (smuggler/baseline) vs 1–2px (detective/augmented) line weight. Custom SVG `stroke-width` responds to `lattice_profile` parameter directly. Icon fonts can't vary weight per-character in a single rendering pass.
|
||||||
|
- **Visual coherence:** Mixing a generic icon font with the custom geometric primitives (diamond POI markers, 6px dots, 4px evidence squares, commission grid) creates two visual registers on the same UI layer. All insert elements must feel like they came from one design system.
|
||||||
|
- **Godot integration:** Godot 4 handles SVG natively (AtlasTexture, importable SVGs). Runtime color via ShaderMaterial. No font rendering pipeline or bitmap caching needed.
|
||||||
|
- **Icon vocabulary (v0.1):**
|
||||||
|
- Inventory item silhouettes: 3–4 items at 40×40px (manifest, access token, comm log) — smuggler only per [D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence)
|
||||||
|
- Perception mode indicators: 2–3 icons at 20×20px (deferred until #315 specs the modes)
|
||||||
|
- Stance indicators: text labels (`WALK`, `CAREFUL`, `SPRINT`, `CROUCH`) at Michroma 11px — icons deferred to v0.2
|
||||||
|
- Border arrows and geometric markers (diamonds, dots, squares): drawn as primitives, not icons
|
||||||
|
- **Authoring constraints:** 1px stroke weight base (scaled to 2px for `lattice_augmented` via parameter), `#c8d0e0` as design reference color (runtime-replaceable), minimal anchor points, no decorative curves.
|
||||||
|
- **Calibration reference:** Phosphor Light (MIT) — not for use, but for evaluating small-size readability of custom shapes during authoring. If a custom icon doesn't read as cleanly as a Phosphor Light equivalent at 16px, simplify it.
|
||||||
|
- **Cross-reference:** Insert overlay ([D-048](#d-048-neural-insert-overlay--visual-design)), insert HUD wireframe (`docs/design/insert-hud-wireframe-v01.md`), visual grammar (`docs/design/visual-grammar-v01.md` §7), cursor states ([D-056](#d-056-cursor-states--insert-styled-geometric)), inventory ([D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence))
|
||||||
|
- **Raised by:** Araminta (analysis and recommendation), project lead (confirmed).
|
||||||
|
- **Dissent:** None.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*37 decisions. Last updated: 2026-02-24 (D-079–D-083: Knowledge Flow & NPC Information Boundaries Workshop)*
|
*38 decisions. Last updated: 2026-02-24 (D-086: Insert icon system)*
|
||||||
|
|||||||
+78
-4
@@ -161,13 +161,87 @@ Tracked questions awaiting discussion or resolution.
|
|||||||
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
||||||
|
|
||||||
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
||||||
- **Status:** Open
|
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
|
||||||
- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content.
|
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
|
||||||
- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace.
|
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
|
||||||
- **Ticket:** #544
|
- **Ticket:** #544
|
||||||
- **Assigned to:** Gestalt, Tyre
|
- **Assigned to:** Gestalt, Tyre
|
||||||
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
||||||
|
|
||||||
|
### Q-029: Save file format design
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** What should the long-term save file format look like? Key considerations:
|
||||||
|
1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load?
|
||||||
|
2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward?
|
||||||
|
3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header?
|
||||||
|
4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save?
|
||||||
|
5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable?
|
||||||
|
6. **Modding:** Should the format be documented for mod authors? Does it need extension points?
|
||||||
|
7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits?
|
||||||
|
- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality.
|
||||||
|
- **Assigned to:** Tyre, Dudley
|
||||||
|
- **Source:** Team Leader directive (Sprint 19 planning)
|
||||||
|
|
||||||
|
### Q-030: Seed configuration schema
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** What artifact records all randomizer decisions at game start? The wiki-review workshop proposed a `seed-state.yaml` capturing: world seed, character selection, pool draws (Tier 1 modules, FRIEND selection, contraband variant), template assignments, NPC trait rolls, triangle configurations, and entanglement pattern. Ticket #394 (seed configuration schema design) exists but the design is open.
|
||||||
|
- **Assigned to:** Tyre, Gestalt
|
||||||
|
- **Source:** Wiki Review Workshop + v0.1 Content Scoping Workshop
|
||||||
|
|
||||||
|
### Q-031: Combined content style guide
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should the project have a single combined content style guide merging Paula's tier templates, Mellanie's voice conventions, Gestalt's mechanical constraints, and Miri's regional guide? The wiki-review workshop proposed this as a deliverable but it was never authored. What format, who owns it, and does it block content authoring?
|
||||||
|
- **Assigned to:** Mellanie, Paula
|
||||||
|
- **Source:** Wiki Review Workshop R2
|
||||||
|
|
||||||
|
### Q-032: Cultural ingredients menu
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should world generation use a 6-category cultural ingredients menu (Heritage Roots, Settlement Motivation, Economic Function, Philosophical Alignment, Corporate/Faction Presence, Drift Stage) where each culture is composed by selecting from ingredient lists? The lead approved the "ingredients menu" model over fixed cultural taxonomies. Full specification needed: category definitions, ingredient lists per category, composition rules, absence-as-signal mechanics.
|
||||||
|
- **Assigned to:** Miri, Gestalt
|
||||||
|
- **Source:** Wiki Review Workshop R4, lead interview
|
||||||
|
|
||||||
|
### Q-033: Three-system NPC architecture
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should NPCs be formally composed from 9 thematic patterns (FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY) x 6 functional motivations (HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC)? D-024 defines 10 axes + combat but predates this refined system. The wiki-review workshop produced a full composition matrix with drama ratings and forbidden combinations. Does this supersede D-024 or extend it?
|
||||||
|
- **Assigned to:** Gestalt, Paula
|
||||||
|
- **Source:** Wiki Review Workshop R4
|
||||||
|
|
||||||
|
### Q-034: PC archetypes
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should the full game support 8 fluid PC archetypes (Smuggler, Detective, Engineer, Diplomat, Medic, Scholar, Soldier, Merchant) with transition mechanics where archetype shifts during play based on player behavior? The lead approved 8 archetypes with fluid transitions as a game mechanic. v0.1 ships smuggler + detective only (D-027). Full archetype spec, transition triggers, and "vulnerable window" mechanics are undesigned. NOTE: The character-creation-game-setup workshop (Q-011) will address this — coordinate.
|
||||||
|
- **Assigned to:** Nigel, Gestalt
|
||||||
|
- **Source:** Wiki Review Workshop R4, lead interview
|
||||||
|
|
||||||
|
### Q-035: Sacred/Profane/Middle Kingdom framework
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should all game systems map to a Sacred/Profane/Middle Kingdom architectural framework? The lead approved this model where Sacred = what the system protects, Profane = what threatens it, Middle Kingdom = where the player navigates. The wiki-review workshop produced a full mapping table covering information, social, economic, spatial, temporal, and narrative systems. Needs formal specification and validation against current architecture.
|
||||||
|
- **Assigned to:** Gore, Gestalt
|
||||||
|
- **Source:** Wiki Review Workshop R4, lead interview
|
||||||
|
|
||||||
|
### Q-036: District skeleton as generator output
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** For the 300-world model, should the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) be the atomic output unit of the world generator? D-025 defines social sites as the atomic template unit for hand-authoring. The generator model reframes the district as a composed output from ingredient inputs. How does this interact with D-025?
|
||||||
|
- **Assigned to:** Tyre, Gestalt
|
||||||
|
- **Source:** Wiki Review Workshop R4
|
||||||
|
|
||||||
|
### Q-037: Generator development pipeline
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Should content production follow a 6-phase generator pipeline (Ingredient Authoring, Template Authoring, Generator Development, Validation Development, Generation + Review, Hand-Elevation)? The wiki-review workshop proposed this as the production model for 300 worlds. SI mapped a release path (v0.1 hand-authored, v0.2-0.5 template expansion, v0.6-0.10 generator development, pre-v1.0 validation). Needs scope assessment and sprint planning integration.
|
||||||
|
- **Assigned to:** SI, Tyre
|
||||||
|
- **Source:** Wiki Review Workshop R4
|
||||||
|
|
||||||
|
### Q-038: Authored content estimate at 300-world scale
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** What is the irreducible authored content volume for 300 worlds? The wiki-review workshop estimated ~1,600-2,800 hours of hand-authoring for generator inputs (ingredient definitions, template specifications, validation rules, hand-elevation passes). How does this compare to the 20-district hand-authoring model it replaced? Is this estimate still valid given subsequent architectural decisions?
|
||||||
|
- **Assigned to:** Mellanie, SI
|
||||||
|
- **Source:** Wiki Review Workshop R4
|
||||||
|
|
||||||
|
### Q-039: Gate topology generation
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** How should the world generator produce gate (wormhole) network topology for 300 worlds? The wiki-review workshop proposed: gate connectivity = Sacred (what connects), which worlds connect = Profane (what separates), accessible world count = Middle Kingdom (where the player navigates). Small-world network properties, hub-and-spoke vs mesh topology, and Sacred/Profane constraints on gate placement are all unresolved. D-012 covers chunk-based map architecture but predates the 300-world model.
|
||||||
|
- **Assigned to:** Tyre, Nigel
|
||||||
|
- **Source:** Wiki Review Workshop R4
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24*
|
*39 questions (7 resolved, 1 partially resolved, 31 open). Last updated: 2026-02-25 (Q-030 through Q-039 added — retroactive filings from Wiki Review Workshop and v0.1 Content Scoping Workshop)*
|
||||||
|
|||||||
+28
-1
@@ -178,6 +178,33 @@ What we're building: game concept, design pillars, prototype definition, map spe
|
|||||||
- **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation: SmallVec<3>), Dudley (server model: BTreeMap + info boundary)
|
- **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation: SmallVec<3>), Dudley (server model: BTreeMap + info boundary)
|
||||||
- **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with minimal implementation after lead directive.
|
- **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with minimal implementation after lead directive.
|
||||||
|
|
||||||
|
### D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** v0.1 vertical slice uses 5 relationship triangles. Three are active forks (T1: Kael-Smuggler-Ring, T2: Sera-Detective-Commission, T4: Drin-System-Ring) with branching outcomes driven by player observation. Two are passive tensions (T3: Naia-Kael-Hael, T5: Worried Partner background) that provide atmosphere and secondary discovery paths. Active forks require authored content per branch. Passive tensions are system-driven.
|
||||||
|
- **Rationale:** Three active forks are within v0.1 content authoring capacity. Passive tensions require no branching content — they enrich discovery space without multiplying authored lines.
|
||||||
|
- **Raised by:** Gestalt, Paula
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis
|
||||||
|
- **Cross-reference:** D-027 (vertical slice), D-034 (THE FRIEND pattern)
|
||||||
|
|
||||||
|
### D-089: Self-contained triangle forks for v0.1, no cross-triangle cascade
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** In v0.1, each triangle fork resolves independently. No triangle outcome triggers escalation in another triangle. Cross-triangle cascade (storyteller-managed, where resolving T1 affects T2 pressure) is deferred to v0.2+. This keeps v0.1 content authoring manageable — each triangle is a self-contained narrative unit.
|
||||||
|
- **Rationale:** Cross-triangle cascade requires the storyteller to track inter-triangle state and authors to write contingent branches. Both are out of scope for v0.1. Self-contained triangles can be authored, tested, and validated independently.
|
||||||
|
- **Raised by:** Paula, Gestalt
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis
|
||||||
|
- **Cross-reference:** D-087 (triangle configuration), D-027 (vertical slice)
|
||||||
|
|
||||||
|
### D-091: Complicity as named thematic core
|
||||||
|
- **Date:** 2026-02-12
|
||||||
|
- **Decision:** The game's thematic identity is complicity — not conspiracy, not detection, not information asymmetry (which is the mechanical core per D-007). The player becomes complicit through observation: seeing something means choosing whether to act on it. The smuggler is complicit in the ring's operations. The detective is complicit in the institution's blindness. Both discover they are already entangled before they choose to be. This framing governs narrative design, wow moment emotional targets (D-039), and the Divergence Reveal (D-027 criterion 4).
|
||||||
|
- **Rationale:** "Complicity" names the emotional experience that information asymmetry produces. It distinguishes this game from pure detective games (you uncover truth) and pure action games (you do things). Here: you watch, and the watching implicates you.
|
||||||
|
- **Raised by:** Gore
|
||||||
|
- **Dissent:** None
|
||||||
|
- **Source:** Wiki Review Workshop, Gore Round 2 proposal, confirmed by lead interview
|
||||||
|
- **Cross-reference:** D-007 (five pillars), D-039 (wow moments), D-027 (vertical slice)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*14 decisions (12 active, 2 superseded). Last updated: 2026-02-13*
|
*17 decisions (15 active, 2 superseded). Last updated: 2026-02-12 (D-087, D-089, D-091 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)*
|
||||||
|
|||||||
+19
-3
@@ -62,11 +62,27 @@ The server must be running before the client connects (subprocess launch will be
|
|||||||
### Test
|
### Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests (test-server + test-client)
|
||||||
make test-server # cargo test in server/
|
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
|
||||||
make test-client # gdUnit4 tests (headless runner pending)
|
make test-client # Godot tests via tests/run-godot (gdUnit4 headless, JSON summary)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The IPC test layers (D-030) have dedicated targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-ipc-fixtures # Layer 1: serialization round-trip fixtures
|
||||||
|
make test-ipc-protocol # Layer 2: mock LocalBridge protocol tests
|
||||||
|
make test-ipc-integration # Layer 3: real subprocess round-trip (+ benchmark when ready)
|
||||||
|
make test-ipc-benchmark # IPC latency benchmark (blocked: #555/#556 handshake)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `tests/run-*` script outputs a JSON summary to stdout and streams progress to stderr:
|
||||||
|
```json
|
||||||
|
{"suite":"rust","total":42,"passed":42,"failed":0,"duration_ms":1230}
|
||||||
|
```
|
||||||
|
|
||||||
|
All scripts accept `--filter <name>` to run a subset of tests. They are whitelistable for agent use (no TTY prompts, no interactive input).
|
||||||
|
|
||||||
Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030).
|
Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030).
|
||||||
|
|
||||||
### Cross-Encoder Fixtures
|
### Cross-Encoder Fixtures
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,100 @@
|
|||||||
|
# Line ID Authoring Guide
|
||||||
|
|
||||||
|
**Decision:** D-084 (dual-namespace line ID scheme)
|
||||||
|
**Resolves:** Q-028 (collision-resistant IDs for auto-generated NPCs)
|
||||||
|
**Ticket:** #544
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Short Version
|
||||||
|
|
||||||
|
- **Role pool lines:** Use `{role-slug}_d_{###}` — e.g., `dock-worker_d_001`. These lines are shared by all NPCs with that role. This is the default for all auto-generated NPC content.
|
||||||
|
- **Named NPC lines:** Use `{npc-slug}_d_{###}` — e.g., `kael-davan_d_001`. Unchanged from current practice.
|
||||||
|
- **Instance-specific lines (rare):** Use `{role-slug}-{counter}_d_{###}` — e.g., `dock-worker-07_d_001`. Only needed when a specific generated NPC needs content different from the role pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Line IDs Work
|
||||||
|
|
||||||
|
A line ID identifies **content**, not speaker. The speaker is identified by their `StableId` in the history log. So `dock-worker_d_001` being said by 40 different dock workers is correct: the log records `(StableId: 12, dock-worker_d_001)`, `(StableId: 37, dock-worker_d_001)`, etc. No collision.
|
||||||
|
|
||||||
|
This means the role pool approach already handles most cases — the "collision problem" is mainly a concern for the rare case where you want a specific generated NPC to say something *different* from others of the same role.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Namespace Reference
|
||||||
|
|
||||||
|
### Named NPC lines (Tier 1 and Tier 2 authored NPCs)
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {npc-slug}_{content-type}_{###}
|
||||||
|
Example: kael-davan_d_001 (Kael's dialogue line 1)
|
||||||
|
sera-venn_d_015 (Sera's dialogue line 15)
|
||||||
|
pc-smuggler_m_s_001 (Smuggler monologue line 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
File location: One file per NPC (e.g., `dialogue/maintenance-corridors/kael-davan.yaml`)
|
||||||
|
|
||||||
|
Numbering: Sequential within the file. Gaps are acceptable (deleted lines leave permanent gaps). Never reuse a number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Role pool lines (auto-generated NPCs, Tier 3 flat, Tier 2 mundane)
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {role-slug}_{content-type}_{###}
|
||||||
|
Example: dock-worker_d_001 (any dock worker, dialogue line 1)
|
||||||
|
bar-regular_d_008 (any bar regular, dialogue line 8)
|
||||||
|
transit-worker_d_003 (any transit worker, dialogue line 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
File location: One file per role-at-location (e.g., `dialogue/the-terminal/dock-worker.yaml`)
|
||||||
|
|
||||||
|
These lines are shared by **all instances** of the role. Write them to suit any dock worker, not a specific one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Instance-specific lines (opt-in, rare)
|
||||||
|
|
||||||
|
Use only when the generation system has flagged a specific NPC as needing content that differs from the role pool. Examples: a generated dock worker who is also a triangle member with a specific tell; a generated bar regular who witnessed a specific event.
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {role-slug}-{zero-padded counter}_{content-type}_{###}
|
||||||
|
Example: dock-worker-07_d_001 (instance 7 of dock-worker role, line 1)
|
||||||
|
bar-regular-02_d_005 (instance 2 of bar-regular role, line 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
The counter (01, 02, ... N) is assigned by the generation system in world-seed-deterministic order. The NPC's generated profile file will tell you which counter to use.
|
||||||
|
|
||||||
|
File location: Same directory as the role pool file, separate file with instance slug as name (e.g., `dialogue/the-terminal/dock-worker-07.yaml`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Decision Guide
|
||||||
|
|
||||||
|
| Situation | ID format to use |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Named authored NPC (Kael, Sera, Voss...) | `{npc-slug}_d_{###}` |
|
||||||
|
| Lines any dock worker can say | `dock-worker_d_{###}` |
|
||||||
|
| Lines any bar regular can say | `bar-regular_d_{###}` |
|
||||||
|
| Generated NPC with specific triangle role | `{role-slug}-{counter}_d_{###}` |
|
||||||
|
| Generated NPC who's just background | `{role-slug}_d_{###}` — no instance ID needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Compatibility
|
||||||
|
|
||||||
|
The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` accepts all three formats. No schema change is required. The content validator (`make validate-content`) checks for duplicate IDs across all files in a district.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Numbering Rules
|
||||||
|
|
||||||
|
1. Start at `001`, increment by 1 for each new line.
|
||||||
|
2. Never reuse a number, even if a line is deleted. Gaps are fine.
|
||||||
|
3. Lines within a single file have a contiguous prefix — `dock-worker_d_001` through `dock-worker_d_042`, etc.
|
||||||
|
4. Cross-file: `kael-davan.yaml` at the terminal and `kael-davan.yaml` at maintenance corridors both use the `kael-davan_d_###` namespace. Continue numbering from where the other file left off (check the existing files first, use a fresh sequence if the NPC is new to a location).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*D-084 — authored by Gestalt, Sprint 18*
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
# Tier 1 Drama Module — Authoring Guide
|
||||||
|
|
||||||
|
**Schema:** `content/schemas/drama_module.schema.yaml`
|
||||||
|
**Module pool:** `content/modules/tier1/*.yaml`
|
||||||
|
**Decisions:** D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern)
|
||||||
|
**Vertical slice reference:** `content/modules/tier1/smuggling_ring_v0_1.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is a Tier 1 Drama Module?
|
||||||
|
|
||||||
|
Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are:
|
||||||
|
|
||||||
|
- **Hand-authored.** Every event sequence, every NPC role, every outcome was written by a person.
|
||||||
|
- **Pool-based.** Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions.
|
||||||
|
- **Optional from the player's perspective.** The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from *systems*, not *scripts*.
|
||||||
|
- **Dual-lens.** Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes.
|
||||||
|
|
||||||
|
What they are **not:**
|
||||||
|
- Not quests with markers or objectives.
|
||||||
|
- Not scripted cutscenes.
|
||||||
|
- Not balanced challenge encounters.
|
||||||
|
|
||||||
|
The storyteller uses the module as a *schedule* — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
schemas/
|
||||||
|
drama_module.schema.yaml ← Schema reference (this file validates against it)
|
||||||
|
modules/
|
||||||
|
tier1/
|
||||||
|
smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module
|
||||||
|
future_module_v0_1.yaml ← Future modules go here
|
||||||
|
```
|
||||||
|
|
||||||
|
One `.yaml` file per drama module. The storyteller's content loader scans `content/modules/tier1/` at startup and adds all valid modules to the pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Reference
|
||||||
|
|
||||||
|
### Identity Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `module_id` | Yes | Stable slug: `{name}_v{major}_{minor}`. Never reuse. Increment on breaking structural change. |
|
||||||
|
| `display_name` | Yes | Human-readable title for dev tooling. Not shown in-game. |
|
||||||
|
| `version` | Yes | Authoring version: `{major}.{minor}`. |
|
||||||
|
| `tier` | Yes | Always `1`. |
|
||||||
|
| `description` | No | One-paragraph design summary. Authoring-only. |
|
||||||
|
| `notes` | No | Design rationale, cross-references. Ignored at load time. |
|
||||||
|
| `dual_lens` | No | How smuggler vs detective experience this module. Authoring-only. **Write this first** — it disciplines the design. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pool Metadata
|
||||||
|
|
||||||
|
Controls how the storyteller samples this module.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `pool.weight` | Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. |
|
||||||
|
| `pool.compatible_districts` | No | District slugs. Omit for "any". |
|
||||||
|
| `pool.incompatible_with` | No | Module IDs that can't run concurrently. |
|
||||||
|
| `pool.max_concurrent` | No | Almost always 1. |
|
||||||
|
|
||||||
|
**Design note on weight:** Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Entry Conditions
|
||||||
|
|
||||||
|
Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines *how* it fires.
|
||||||
|
|
||||||
|
#### World-State Condition Types
|
||||||
|
|
||||||
|
| Type | Required Fields | Use When |
|
||||||
|
|------|----------------|----------|
|
||||||
|
| `npc_present` | `role` | The module requires a specific NPC to be in the district. |
|
||||||
|
| `location_accessible` | `location` | The module requires a location the player can physically reach. |
|
||||||
|
| `fact_not_known` | `fact_id` | Module shouldn't activate if a precondition has already been discovered. |
|
||||||
|
| `no_active_module` | `module_id` | Prevents two incompatible modules running at once. |
|
||||||
|
| `fact_known` | `fact_id`, `known_by` | Module requires prior knowledge to make sense. |
|
||||||
|
|
||||||
|
#### Player Conditions (Optional)
|
||||||
|
|
||||||
|
Player conditions are *optional* — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state.
|
||||||
|
|
||||||
|
#### Activation Triggers
|
||||||
|
|
||||||
|
| Trigger | When to Use |
|
||||||
|
|---------|-------------|
|
||||||
|
| `storyteller_push` | Default. Storyteller activates on its own pacing. Most Tier 1 modules. |
|
||||||
|
| `proximity` | Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. |
|
||||||
|
| `player_action` | Reserved for modules that require player initiation. Use rarely. |
|
||||||
|
|
||||||
|
**The `min_play_ticks` field is load-bearing for D-027 criterion #1.** At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set `min_play_ticks` to at least 1800. The vertical slice uses 2100 to give extra breathing room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NPC Requirements
|
||||||
|
|
||||||
|
Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `role` | Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. |
|
||||||
|
| `display_hint` | No | Authoring note: who this role is narratively. |
|
||||||
|
| `binding` | Yes | `named` (specific authored NPC) or `generated` (any matching NPC). |
|
||||||
|
| `named_npc` | Conditional | Required when `binding: named`. Short-form canonical ID: `npc:{slug}`. |
|
||||||
|
| `axes` | Conditional | Required when `binding: generated`. Axis constraints the NPC must satisfy. |
|
||||||
|
| `must_have_pattern` | No | Optional NPC pattern (D-024 System A). |
|
||||||
|
| `must_have_motivation` | No | Optional NPC motivation (D-024 System B). |
|
||||||
|
| `is_optional` | No | Default false. If true, module runs without this slot filled (degraded experience). |
|
||||||
|
|
||||||
|
#### Named vs. Generated Bindings
|
||||||
|
|
||||||
|
**Named bindings** reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for:
|
||||||
|
- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior
|
||||||
|
- NPCs with unique relationships in the 5-triangle web
|
||||||
|
- Roles where voice, history, and moral weight matter
|
||||||
|
|
||||||
|
**Generated bindings** are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
axes:
|
||||||
|
- axis: secret
|
||||||
|
constraint: has_major_secret
|
||||||
|
- axis: contentment
|
||||||
|
constraint: min_contentment_-3 # Discontented, susceptible to opportunity
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraint conventions: `has_{value}`, `min_{N}`, `not_{value}`. The server's NPC filter system interprets these.
|
||||||
|
|
||||||
|
#### What "Roles" Are Not
|
||||||
|
|
||||||
|
NPC roles in a drama module are **not** the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are:
|
||||||
|
- Functional slots within the module's narrative (ring-leader, witness, evidence-holder)
|
||||||
|
- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module
|
||||||
|
- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug
|
||||||
|
|
||||||
|
#### NPC Pattern and Motivation Reference
|
||||||
|
|
||||||
|
Patterns (System A, `must_have_pattern`) encode the NPC's thematic function in the player's experience:
|
||||||
|
|
||||||
|
| Pattern | What It Means |
|
||||||
|
|---------|---------------|
|
||||||
|
| `FRIEND` | Emotionally complex anchor; the contradiction arc lives here (D-034) |
|
||||||
|
| `MIRROR` | Reflects the player character's own path back at them |
|
||||||
|
| `ANCHOR` | Reliable presence; stability the player can always return to |
|
||||||
|
| `GHOST` | Presence felt more than seen; past hangs over current events |
|
||||||
|
| `CATALYST` | Actions cause cascading effects on other NPCs |
|
||||||
|
| `THRESHOLD` | Gatekeeper; controls access to deeper information or relationships |
|
||||||
|
| `REMNANT` | Survivor of a prior event; carries knowledge others want buried |
|
||||||
|
| `SYSTEM` | Embodies an institution or faction rather than personal stakes |
|
||||||
|
| `NOBODY` | Genuinely flat; texture and atmosphere, no arc |
|
||||||
|
|
||||||
|
Motivations (System B, `must_have_motivation`) encode why the NPC acts within the module's conspiracy:
|
||||||
|
|
||||||
|
| Motivation | What It Means |
|
||||||
|
|------------|---------------|
|
||||||
|
| `HANDLER` | Organizes or directs others; the operational center |
|
||||||
|
| `WITNESS` | Knows something they haven't decided to act on |
|
||||||
|
| `TURNCOAT` | Wants out, or has already switched allegiance |
|
||||||
|
| `CIVILIAN` | No conspiracy involvement; proximity creates moral weight |
|
||||||
|
| `OPERATOR` | Executes tasks; functional cog in the system |
|
||||||
|
| `SKEPTIC` | Doubts the conspiracy exists; useful foil for investigation |
|
||||||
|
|
||||||
|
**Full definitions and canonical usage:** `decisions/content.md` D-024.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them.
|
||||||
|
|
||||||
|
#### Sequences vs. Pools
|
||||||
|
|
||||||
|
| Structure | Use For |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Sequence** | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. |
|
||||||
|
| **Pool** | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. |
|
||||||
|
|
||||||
|
The vertical slice uses:
|
||||||
|
- `kael_exit_arc` (sequence) — Kael's ordered character arc
|
||||||
|
- `investigation_pressure` (sequence) — Parallel pressure escalation
|
||||||
|
- `ambient_ring_activity` (pool) — Background ring business that runs throughout
|
||||||
|
|
||||||
|
Most modules should have 1-2 sequences plus 1 pool.
|
||||||
|
|
||||||
|
#### Event Step Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `event_id` | Yes | Unique within module. Used in outcome conditions and `ticks_since_event` triggers. |
|
||||||
|
| `label` | No | Short human-readable label for dev tooling. |
|
||||||
|
| `description` | No | What happens narratively. Write this first — events should have a clear observable presence. |
|
||||||
|
| `triggers` | Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. |
|
||||||
|
| `effects` | No | What changes in the world. |
|
||||||
|
| `once` | No | Default `true`. Set `false` for repeating events (ambient discrepancies, etc.). |
|
||||||
|
| `sets_flag` | No | Module-internal flag set when event fires. Used in outcome conditions. |
|
||||||
|
|
||||||
|
#### Trigger Types
|
||||||
|
|
||||||
|
| Type | Fires When | Key Fields |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| `ticks_since_activation` | N ticks after module activated | `ticks` |
|
||||||
|
| `ticks_since_event` | N ticks after a previous event fired | `after_event`, `ticks` |
|
||||||
|
| `player_proximity` | Player near NPC/location | `target_type`, `target`, `radius_tiles` |
|
||||||
|
| `player_action` | Player interacts with target | `action`, `target_role` |
|
||||||
|
| `fact_known_by_player` | Player has discovered a fact | `fact_id` |
|
||||||
|
| `flag_set` | A module flag has been set | `flag` |
|
||||||
|
| `npc_mood` | NPC enters a mood state | `npc_role`, `mood` |
|
||||||
|
|
||||||
|
**Design principle: events should fire without the player.** Every event must have at least one tick-based trigger (`ticks_since_activation` or `ticks_since_event`). Proximity and action triggers are secondary paths that fire the event *earlier* if the player engages. The world moves at its own pace; the player accelerates or delays, not controls.
|
||||||
|
|
||||||
|
#### Effect Types
|
||||||
|
|
||||||
|
| Type | Use For |
|
||||||
|
|------|---------|
|
||||||
|
| `npc_routine_deviation` | Visible NPC behavior change. Write this descriptively — it's what the player sees. |
|
||||||
|
| `fact_becomes_discoverable` | Gates a fact into the knowledge graph at Rumoured confidence. |
|
||||||
|
| `tell_intensify` | NPC's tell behavior becomes more frequent/pronounced. |
|
||||||
|
| `flag_set` | Internal state tracking. Not visible to player. |
|
||||||
|
| `location_state` | Something visible changes in a location. |
|
||||||
|
| `npc_knowledge_update` | An NPC learns something new. |
|
||||||
|
|
||||||
|
**On `fact_becomes_discoverable`:** This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The `discovery_method` field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog.
|
||||||
|
|
||||||
|
**Fact ID convention:** Use `{module-slug}.{fact_name}` — e.g., `ring.kael_unauthorized_corridor_access`. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check `content/global/knowledge/` to see if an equivalent fact already exists; reuse it rather than creating a duplicate.
|
||||||
|
|
||||||
|
**Mapping `discovery_method` to D-035 trigger types:** The `discovery_method` note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in `decisions/content.md` D-035 and `content/global/enums/triggers.yaml`):
|
||||||
|
|
||||||
|
| If discovery happens via… | D-035 trigger type | What to author |
|
||||||
|
|--------------------------|-------------------|----------------|
|
||||||
|
| Player enters the location where something is visible | `enter_location` | Monologue line flagging the anomaly on arrival |
|
||||||
|
| Player watches an NPC doing something unusual | `observe_npc` | Monologue line on NPC observation; dialogue option unlocks |
|
||||||
|
| Player examines an object or terminal | `observe_anomaly` | Examine verb interaction; monologue on result |
|
||||||
|
| Player witnesses two NPCs interacting | `witness_interaction` | Monologue line; trust-gated gossip unlock |
|
||||||
|
| Player finishes a conversation with the relevant NPC | `post_conversation` | Monologue beat after talking to the NPC |
|
||||||
|
| Player discovers a physical object (cargo, message) | `discover_evidence` | Examine verb; monologue on discovery |
|
||||||
|
| Player returns to a location they've been before | `return_visit` | Monologue on changed state vs. prior visit |
|
||||||
|
|
||||||
|
Write the `discovery_method` note to specify which of these applies — ideally two methods for redundancy (e.g., `enter_location` plus `observe_anomaly`) so players aren't funneled into a single approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Outcomes
|
||||||
|
|
||||||
|
Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied.
|
||||||
|
|
||||||
|
**Every module must include:**
|
||||||
|
- At least one terminal outcome that represents "the investigation succeeded"
|
||||||
|
- At least one terminal outcome that represents "the conspiracy ran its course"
|
||||||
|
- Exactly one expiry outcome (`is_expiry: true`) for quiet player non-engagement
|
||||||
|
|
||||||
|
#### Outcome Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `outcome_id` | Yes | Unique slug. |
|
||||||
|
| `label` | Yes | Short label. |
|
||||||
|
| `is_terminal` | Yes | `true` = module ends. `false` = transitional state (module can continue evolving). |
|
||||||
|
| `is_expiry` | No | `true` = this is the quiet-exit outcome. One per module. |
|
||||||
|
| `conditions` | No | ALL conditions must be true. See below. |
|
||||||
|
| `effects` | No | World changes when outcome is reached. |
|
||||||
|
|
||||||
|
**On `is_terminal: false`:** A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the `ring_splinters` outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream.
|
||||||
|
|
||||||
|
#### Outcome Conditions
|
||||||
|
|
||||||
|
| Condition | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `facts_known` | Player must know all listed facts. |
|
||||||
|
| `facts_not_known` | Player must NOT know any listed facts. |
|
||||||
|
| `flags_set` | All listed module flags must be set. |
|
||||||
|
| `flags_not_set` | None of listed flags may be set. |
|
||||||
|
| `events_fired` | All listed events must have fired. |
|
||||||
|
| `ticks_since_activation` | Module has been running for at least N ticks. |
|
||||||
|
|
||||||
|
#### Outcome Effects
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `npc_disposition` | NPC's relationship state with player shifts. |
|
||||||
|
| `faction_reaction` | Faction reputation change. |
|
||||||
|
| `location_access_change` | Location becomes restricted, locked, or open. |
|
||||||
|
| `fact_state` | Fact is permanently known, hidden, or destroyed. |
|
||||||
|
| `npc_exit` | NPC leaves the district or becomes inaccessible. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles for Tier 1 Modules
|
||||||
|
|
||||||
|
### 1. The World Moves First
|
||||||
|
|
||||||
|
Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module.
|
||||||
|
|
||||||
|
### 2. Both Characters Must Have a Story
|
||||||
|
|
||||||
|
Every event and outcome must mean something different to the smuggler and the detective. Write the `dual_lens` authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1.
|
||||||
|
|
||||||
|
### 3. No Clean Resolutions
|
||||||
|
|
||||||
|
D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design.
|
||||||
|
|
||||||
|
### 4. THE FRIEND Contradiction Is the Pivot
|
||||||
|
|
||||||
|
If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be:
|
||||||
|
- **Observable from spatial positioning** — not from dialogue, not from menus
|
||||||
|
- **Ambiguous before context** — the player sees the behavior before they understand what it means
|
||||||
|
- **Irreversible once witnessed** — seeing changes the relationship, even if the player does nothing
|
||||||
|
|
||||||
|
The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw.
|
||||||
|
|
||||||
|
### 5. Expiry Is Not Failure
|
||||||
|
|
||||||
|
The `module_abandoned` expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive.
|
||||||
|
|
||||||
|
### 6. Facts, Not Flags, Drive Investigation
|
||||||
|
|
||||||
|
Facts (from `global/knowledge/`) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag.
|
||||||
|
|
||||||
|
Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation and Format Rules (Gestalt)
|
||||||
|
|
||||||
|
These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (`make validate-content`), but catching them during authoring saves a pipeline run.
|
||||||
|
|
||||||
|
### ID and Slug Formats
|
||||||
|
|
||||||
|
| Field | Regex | Example |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `module_id` | `^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$` | `smuggling_ring_v0_1` |
|
||||||
|
| `sequence_id`, `pool_id` | `^[a-z][a-z0-9_-]*$` | `kael_exit_arc` |
|
||||||
|
| `event_id` | `^[a-z][a-z0-9_-]*$` | `kael_goes_cold` |
|
||||||
|
| `outcome_id` | `^[a-z][a-z0-9_-]*$` | `ring_exposed` |
|
||||||
|
| `sets_flag` / flag references | `^[a-z][a-z0-9_-]*$` | `kael_behavior_changed` |
|
||||||
|
| `role` (npc slot) | `^[a-z][a-z0-9-]*$` | `ring-member-exiting` |
|
||||||
|
| `named_npc` | `^npc:[a-z][a-z0-9-]*$` | `npc:kael-davan` |
|
||||||
|
| `version` | `^[0-9]+\\.[0-9]+$` | `0.1` |
|
||||||
|
|
||||||
|
Note the difference: `event_id`, `outcome_id`, `sequence_id`, and flags use underscores and hyphens (`[a-z0-9_-]*`). NPC `role` slugs use hyphens only (`[a-z0-9-]*`). Mixing them in wrong fields will fail schema validation.
|
||||||
|
|
||||||
|
### Flag Naming Convention
|
||||||
|
|
||||||
|
Flags are module-internal state. Every flag name that appears in `sets_flag` on an event **must** also appear in at least one outcome's `flags_set` or `flags_not_set` condition — or the flag serves no purpose. Convention:
|
||||||
|
|
||||||
|
- Use `snake_case` with underscores: `kael_behavior_changed`, `voss_pressure_applied`
|
||||||
|
- Name by what happened, not what it enables: `handler_pressure_applied` not `kael_ready_to_flee`
|
||||||
|
- Flags set by events accumulate — they are never automatically cleared
|
||||||
|
- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below)
|
||||||
|
|
||||||
|
### Axis Constraint Syntax (Generated NPC Bindings)
|
||||||
|
|
||||||
|
The `constraint` field in `axes` is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce):
|
||||||
|
|
||||||
|
| Prefix | Example | Meaning |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `has_` | `has_major_secret` | NPC axis value includes this descriptor |
|
||||||
|
| `min_contentment_` | `min_contentment_-3` | Contentment axis value ≤ N (more discontented) |
|
||||||
|
| `not_` | `not_combat_trained` | Axis value does NOT include this descriptor |
|
||||||
|
| `is_` | `is_ring_member` | Boolean flag set on NPC profile |
|
||||||
|
|
||||||
|
### What JSON Schema Cannot Validate (Tier 2 Catches These)
|
||||||
|
|
||||||
|
| Issue | Where to Look | Impact |
|
||||||
|
|-------|--------------|--------|
|
||||||
|
| `fact_id` not defined in `global/knowledge/` | Effect `fact_becomes_discoverable`, outcome `facts_known` | Fact silently never becomes discoverable |
|
||||||
|
| `sets_flag` name not referenced in any outcome condition | Event `sets_flag` | Flag is set but never meaningful |
|
||||||
|
| `flags_set`/`flags_not_set` reference flag never set by any event | Outcome conditions | Condition permanently true or false |
|
||||||
|
| `ticks_since_event.after_event` references unknown event_id | Event trigger | Trigger never fires |
|
||||||
|
| `named_npc` ID doesn't exist in district NPC profiles | NPC requirements | Load-time failure |
|
||||||
|
| Multiple outcomes have `is_expiry: true` | Outcomes list | Undefined storyteller behavior |
|
||||||
|
| `faction` in outcome effects not in `global/factions/` | Outcome effects | Effect silently ignored |
|
||||||
|
|
||||||
|
### The Expiry Condition Pitfall
|
||||||
|
|
||||||
|
This is the most common authoring mistake for expiry outcomes. **The expiry condition must use `facts_not_known`, not `flags_not_set`.** Reason:
|
||||||
|
|
||||||
|
Events with `ticks_since_activation` triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks `flags_not_set: [that_flag]`, the expiry condition becomes permanently false after the event fires — the module can never expire quietly.
|
||||||
|
|
||||||
|
**Wrong:**
|
||||||
|
```yaml
|
||||||
|
# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed
|
||||||
|
# This expiry can never fire after tick 300
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
is_expiry: true
|
||||||
|
conditions:
|
||||||
|
flags_not_set:
|
||||||
|
- kael_behavior_changed # This flag is always set by tick 300
|
||||||
|
ticks_since_activation: 5400
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
```yaml
|
||||||
|
# facts_not_known gates on player investigative action, not auto-fired events
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
is_expiry: true
|
||||||
|
conditions:
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # Only known if player examined terminal
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael
|
||||||
|
ticks_since_activation: 5400
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Before Submitting a New Module
|
||||||
|
|
||||||
|
- [ ] `module_id` uses correct format and doesn't collide with existing modules
|
||||||
|
- [ ] `dual_lens` is written and shows clearly different experiences per character
|
||||||
|
- [ ] `min_play_ticks` ≥ 1800 (30 minutes at 1 tick/second)
|
||||||
|
- [ ] Every event sequence step has at least one tick-based trigger
|
||||||
|
- [ ] Every `fact_becomes_discoverable` effect has a `discovery_method` note
|
||||||
|
- [ ] The module includes at least one named FRIEND-pattern NPC (for v0.1 modules)
|
||||||
|
- [ ] Expiry outcome is present (`is_expiry: true`) with conditions gated on `facts_not_known`, NOT `flags_not_set`
|
||||||
|
- [ ] All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution
|
||||||
|
- [ ] `npc_requirements` covers every role referenced in events and outcomes
|
||||||
|
- [ ] All fact IDs used in effects/conditions exist in `global/knowledge/`
|
||||||
|
- [ ] All `sets_flag` names appear in at least one outcome condition
|
||||||
|
- [ ] All `flags_set`/`flags_not_set` names are set by at least one event's `sets_flag`
|
||||||
|
- [ ] `make validate-content` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
| Topic | Location |
|
||||||
|
|-------|----------|
|
||||||
|
| Three-tier content model | `decisions/content.md` D-023 |
|
||||||
|
| NPC 10-axis model | `decisions/content.md` D-024 |
|
||||||
|
| Vertical slice scope | `decisions/scope.md` D-027 |
|
||||||
|
| Population ratios | `decisions/content.md` D-029 |
|
||||||
|
| THE FRIEND pattern | `decisions/content.md` D-034 |
|
||||||
|
| Smuggling ring module | `content/modules/tier1/smuggling_ring_v0_1.yaml` |
|
||||||
|
| Drama module schema | `content/schemas/drama_module.schema.yaml` |
|
||||||
|
| Fact catalog | `content/global/knowledge/` |
|
||||||
|
| NPC profiles (v0.1) | `content/campaigns/main/systems/krenn/` |
|
||||||
|
| Storyteller stub | `server/src/storyteller/mod.rs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).*
|
||||||
@@ -60,7 +60,7 @@ Main menu, pause, save/load, options.
|
|||||||
|-----------|---------|-------------------|
|
|-----------|---------|-------------------|
|
||||||
| [v01-main-menu](menus/v01-main-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (functional warmth style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (two-character proof — character select) |
|
| [v01-main-menu](menus/v01-main-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (functional warmth style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (two-character proof — character select) |
|
||||||
| [v01-pause-menu](menus/v01-pause-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
|
| [v01-pause-menu](menus/v01-pause-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
|
||||||
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
|
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-085](../../../decisions/architecture.md#d-085-per-game-save-directory-structure) (per-game save dirs), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice) |
|
||||||
| [v10-main-menu](menus/v10-main-menu.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-036](../../../decisions/content.md#d-036-sova-transit-district--krenn-system-as-v01-setting) (setting — Sova Transit District), [D-013](../../../decisions/scope.md#d-013-diegetic-insertpoi-navigation-system) (diegetic insert — in-fiction menu) |
|
| [v10-main-menu](menus/v10-main-menu.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-036](../../../decisions/content.md#d-036-sova-transit-district--krenn-system-as-v01-setting) (setting — Sova Transit District), [D-013](../../../decisions/scope.md#d-013-diegetic-insertpoi-navigation-system) (diegetic insert — in-fiction menu) |
|
||||||
| [v10-options-full](menus/v10-options-full.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-068](../../../decisions/architecture.md#d-068-5-bus-audio-architecture) (5-bus audio — per-bus volume controls), [D-069](../../../decisions/perception.md#d-069-audio-dip-profiles-for-dialogue-and-confrontation) (audio dip profiles) |
|
| [v10-options-full](menus/v10-options-full.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-068](../../../decisions/architecture.md#d-068-5-bus-audio-architecture) (5-bus audio — per-bus volume controls), [D-069](../../../decisions/perception.md#d-069-audio-dip-profiles-for-dialogue-and-confrontation) (audio dip profiles) |
|
||||||
|
|
||||||
|
|||||||
@@ -37,156 +37,220 @@
|
|||||||
"tab-save": {
|
"tab-save": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 116, "width": 120, "height": 32,
|
"left": 140, "top": 116, "width": 120, "height": 32,
|
||||||
"fillColor": "#1a2030",
|
"fillColor": "#0d1018",
|
||||||
"strokeColor": "#c8d0e0",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 0, 0]
|
"corners": [2, 2, 0, 0]
|
||||||
},
|
},
|
||||||
"tab-save-text": {
|
"tab-save-text": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 156, "top": 125,
|
"left": 156, "top": 125,
|
||||||
"text": "SAVE",
|
"text": "SAVE",
|
||||||
"fontColor": "#c8d0e0",
|
"fontColor": "#556677",
|
||||||
"fontSize": 13
|
"fontSize": 13
|
||||||
},
|
},
|
||||||
"tab-load": {
|
"tab-load": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 264, "top": 116, "width": 120, "height": 32,
|
"left": 264, "top": 116, "width": 120, "height": 32,
|
||||||
"fillColor": "#0d1018",
|
"fillColor": "#1a2030",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#c8d0e0",
|
||||||
"corners": [2, 2, 0, 0]
|
"corners": [2, 2, 0, 0]
|
||||||
},
|
},
|
||||||
"tab-load-text": {
|
"tab-load-text": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 280, "top": 125,
|
"left": 280, "top": 125,
|
||||||
"text": "LOAD",
|
"text": "LOAD",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#c8d0e0",
|
||||||
"fontSize": 13
|
"fontSize": 13
|
||||||
},
|
},
|
||||||
"save-slot-1-active": {
|
|
||||||
|
"game-1-header": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 156, "width": 860, "height": 72,
|
"left": 140, "top": 156, "width": 860, "height": 32,
|
||||||
|
"fillColor": "#151a24",
|
||||||
|
"strokeColor": "#333340",
|
||||||
|
"corners": [2, 2, 0, 0]
|
||||||
|
},
|
||||||
|
"game-1-title": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 152, "top": 165,
|
||||||
|
"text": "\u25bc DETECTIVE \u2014 Day 3 // Sova Transit // last played: today",
|
||||||
|
"fontColor": "#c8d0e0",
|
||||||
|
"fontSize": 12
|
||||||
|
},
|
||||||
|
"game-1-count": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 920, "top": 165,
|
||||||
|
"text": "3 saves",
|
||||||
|
"fontColor": "#556677",
|
||||||
|
"fontSize": 11
|
||||||
|
},
|
||||||
|
|
||||||
|
"qs-row": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 158, "top": 192, "width": 842, "height": 62,
|
||||||
"fillColor": "#1a2030",
|
"fillColor": "#1a2030",
|
||||||
"strokeColor": "#c8d8f0",
|
"strokeColor": "#c8d8f0",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"save-slot-1-accent": {
|
"qs-accent": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 156, "width": 3, "height": 72,
|
"left": 158, "top": 192, "width": 3, "height": 62,
|
||||||
"fillColor": "#c8d8f0",
|
"fillColor": "#c8d8f0",
|
||||||
"strokeColor": "#c8d8f0"
|
"strokeColor": "#c8d8f0"
|
||||||
},
|
},
|
||||||
"slot-1-date": {
|
"qs-label": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 162,
|
"left": 170, "top": 200,
|
||||||
"text": "AUTOSAVE // Day 1, 07:42",
|
"text": "QUICKSAVE // Day 3, 14:22",
|
||||||
"fontColor": "#c8d0e0",
|
"fontColor": "#c8d0e0",
|
||||||
"fontSize": 13
|
"fontSize": 13
|
||||||
},
|
},
|
||||||
|
"qs-location": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 218,
|
||||||
|
"text": "The Terminal \u2014 afternoon shift",
|
||||||
|
"fontColor": "#8899aa",
|
||||||
|
"fontSize": 12
|
||||||
|
},
|
||||||
|
"qs-timestamp": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 234,
|
||||||
|
"text": "saved: 2026-02-25 16:31",
|
||||||
|
"fontColor": "#556677",
|
||||||
|
"fontSize": 11
|
||||||
|
},
|
||||||
|
"qs-actions": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 840, "top": 212,
|
||||||
|
"text": "[Enter] Load\n[F6] Quickload",
|
||||||
|
"fontColor": "#c8d8f0",
|
||||||
|
"fontSize": 11
|
||||||
|
},
|
||||||
|
|
||||||
|
"auto-row": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 158, "top": 260, "width": 842, "height": 62,
|
||||||
|
"fillColor": "#0e1118",
|
||||||
|
"strokeColor": "#333340",
|
||||||
|
"corners": [2, 2, 2, 2]
|
||||||
|
},
|
||||||
|
"auto-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 268,
|
||||||
|
"text": "AUTOSAVE // Day 3, 13:45",
|
||||||
|
"fontColor": "#8899aa",
|
||||||
|
"fontSize": 13
|
||||||
|
},
|
||||||
|
"auto-location": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 286,
|
||||||
|
"text": "Corridor B-7",
|
||||||
|
"fontColor": "#556677",
|
||||||
|
"fontSize": 12
|
||||||
|
},
|
||||||
|
"auto-timestamp": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 302,
|
||||||
|
"text": "saved: 2026-02-25 16:15",
|
||||||
|
"fontColor": "#3a4455",
|
||||||
|
"fontSize": 11
|
||||||
|
},
|
||||||
|
|
||||||
|
"slot-1-row": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 158, "top": 328, "width": 842, "height": 62,
|
||||||
|
"fillColor": "#0e1118",
|
||||||
|
"strokeColor": "#333340",
|
||||||
|
"corners": [2, 2, 2, 2]
|
||||||
|
},
|
||||||
|
"slot-1-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 170, "top": 336,
|
||||||
|
"text": "SLOT 1 // Day 2, 22:10",
|
||||||
|
"fontColor": "#8899aa",
|
||||||
|
"fontSize": 13
|
||||||
|
},
|
||||||
"slot-1-location": {
|
"slot-1-location": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 180,
|
"left": 170, "top": 354,
|
||||||
"text": "The Terminal — morning shift // Detective",
|
"text": "Hab quarters \u2014 evening",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#556677",
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-1-timestamp": {
|
"slot-1-timestamp": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 198,
|
"left": 170, "top": 370,
|
||||||
"text": "saved: 2026-02-23 14:31",
|
"text": "saved: 2026-02-25 14:48",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"slot-1-actions": {
|
|
||||||
"type": "Text",
|
"game-2-header": {
|
||||||
"left": 860, "top": 175,
|
|
||||||
"text": "[Enter] Overwrite / Load",
|
|
||||||
"fontColor": "#c8d8f0",
|
|
||||||
"fontSize": 12
|
|
||||||
},
|
|
||||||
"save-slot-2": {
|
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 236, "width": 860, "height": 72,
|
"left": 140, "top": 404, "width": 860, "height": 32,
|
||||||
"fillColor": "#0e1118",
|
"fillColor": "#111520",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"slot-2-date": {
|
"game-2-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 250,
|
"left": 152, "top": 413,
|
||||||
"text": "SLOT 2 // Day 1, 06:15",
|
"text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#8899aa",
|
||||||
"fontSize": 13
|
|
||||||
},
|
|
||||||
"slot-2-location": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 152, "top": 268,
|
|
||||||
"text": "Arrival — entering Sova Transit // Detective",
|
|
||||||
"fontColor": "#556677",
|
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-2-timestamp": {
|
"game-2-count": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 286,
|
"left": 920, "top": 413,
|
||||||
"text": "saved: 2026-02-23 13:10",
|
"text": "2 saves",
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"save-slot-3": {
|
|
||||||
|
"game-3-header": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 316, "width": 860, "height": 72,
|
"left": 140, "top": 444, "width": 860, "height": 32,
|
||||||
"fillColor": "#0e1118",
|
"fillColor": "#111520",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"slot-3-date": {
|
"game-3-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 330,
|
"left": 152, "top": 453,
|
||||||
"text": "SLOT 3 // Day 1, 07:30",
|
"text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#8899aa",
|
||||||
"fontSize": 13
|
|
||||||
},
|
|
||||||
"slot-3-location": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 152, "top": 348,
|
|
||||||
"text": "Corridor B-7 — Kael spotted // Smuggler",
|
|
||||||
"fontColor": "#556677",
|
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-3-timestamp": {
|
"game-3-count": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 366,
|
"left": 920, "top": 453,
|
||||||
"text": "saved: 2026-02-23 12:48",
|
"text": "5 saves",
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"empty-slots-label": {
|
|
||||||
|
"footer-note": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 140, "top": 400,
|
"left": 140, "top": 660, "width": 860,
|
||||||
"text": "SLOTS 4-8 — empty",
|
"text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload",
|
||||||
"fontColor": "#2a3040",
|
|
||||||
"fontSize": 12
|
|
||||||
},
|
|
||||||
"save-note": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 140, "top": 660,
|
|
||||||
"text": "Autosave on: zone transitions, conversation ends, significant events",
|
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11,
|
||||||
|
"wordWrap": true
|
||||||
},
|
},
|
||||||
"annotation-title": {
|
"annotation-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 16, "top": 720,
|
"left": 16, "top": 720,
|
||||||
"text": "v0.1 SAVE / LOAD SCREEN",
|
"text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#556677",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"annotation-notes": {
|
"annotation-notes": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 16, "top": 734, "width": 1100,
|
"left": 16, "top": 734, "width": 1100,
|
||||||
"text": "Tab UI: Save / Load. Slots show: timestamp, in-game time+location, character. Autosave shown separately. Active slot has accent bar. No screenshots in v0.1.",
|
"text": "Games grouped by directory (D-085). Expand to see saves. QUICKSAVE + AUTOSAVE are system slots; manual slots below. SAVE tab shows current game only. F5/F6 global hotkeys for quicksave/quickload.",
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 10,
|
"fontSize": 10,
|
||||||
"wordWrap": true
|
"wordWrap": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,122 @@
|
|||||||
|
# Sprint 19: Persist — CI Tasks
|
||||||
|
|
||||||
|
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||||
|
|
||||||
|
**Branch:** `ci`
|
||||||
|
**Agents:** Hoshe (QA/CI), Oscar (networking)
|
||||||
|
|
||||||
|
## Carry-over from Sprint 18
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #270 | Test runner bash scripts | — |
|
||||||
|
| #556 | Protocol version handshake: client | #555 (server) |
|
||||||
|
| #342 | IPC round-trip timing benchmark | #555, #556 |
|
||||||
|
| #271 | IPC serialization fixture files | #270 |
|
||||||
|
|
||||||
|
Use `db/connectors/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack codec, SimBridge trait), D-030 (three-layer test architecture: fixture / mock-protocol / real-subprocess)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #270 — Test runner bash scripts
|
||||||
|
|
||||||
|
The test infrastructure has no standardized entry points for CI or agents to invoke. This ticket ships the runner layer.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
Six scripts at `tests/`:
|
||||||
|
1. `tests/run-rust` — runs `cargo test` in `server/`, exits 0/non-zero, JSON stdout summary
|
||||||
|
2. `tests/run-godot` — runs GUT headlessly (`godot --headless -s client/tests/run_gut.gd`), exits 0/non-zero
|
||||||
|
3. `tests/run-ipc-fixtures` — Layer 1: reads fixture files from `tests/fixtures/`, validates via Rust + GDScript, exits 0/non-zero
|
||||||
|
4. `tests/run-ipc-protocol` — Layer 2: runs mock subprocess protocol state machine tests
|
||||||
|
5. `tests/run-ipc-integration` — Layer 3: starts real server subprocess, runs full round-trip, kills it
|
||||||
|
6. `tests/run-all` — invokes all five in order, collects exit codes, reports JSON summary
|
||||||
|
|
||||||
|
Script requirements per ticket description: exit code 0/non-zero, structured JSON stdout, accepts filter arguments (`--filter test_name`), no interactive input, whitelistable for Claude Code agents (no TTY prompts).
|
||||||
|
|
||||||
|
JSON stdout format (consistent across all scripts):
|
||||||
|
```json
|
||||||
|
{"suite": "rust", "total": 42, "passed": 42, "failed": 0, "duration_ms": 1230}
|
||||||
|
```
|
||||||
|
|
||||||
|
These scripts are the entry points that `make ci-server`, `make ci-client`, and future CI pipelines call. Coordinate with Makefile targets in `docs/DEVOPS.md`.
|
||||||
|
|
||||||
|
### #556 — Protocol version handshake: client
|
||||||
|
|
||||||
|
Blocked by #555 (server must send `HandshakeMessage` first).
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- `client/scripts/protocol/local_bridge.gd` (or `server_process.gd`): after starting the server subprocess, read the first framed message from the IPC channel
|
||||||
|
- Validate it is a `HandshakeMessage` with `protocol_version == Protocol.PROTOCOL_VERSION` (14)
|
||||||
|
- If mismatch: log error "Protocol version mismatch: server=%d, client=%d", emit a `handshake_failed` signal, shut down the server process gracefully
|
||||||
|
- If match: emit `handshake_complete`, begin normal tick loop
|
||||||
|
- Add a timeout: if no handshake message received within 5 seconds of process start, treat as mismatch
|
||||||
|
|
||||||
|
Current state: `client/scripts/protocol/protocol.gd` already checks `version` in `decode_snapshot()` and logs a mismatch. That check is per-snapshot. The handshake is the startup-time equivalent — validate once at connection, not per tick.
|
||||||
|
|
||||||
|
Files: `client/scripts/protocol/local_bridge.gd`, `client/scripts/protocol/server_process.gd`.
|
||||||
|
|
||||||
|
### #342 — IPC round-trip timing benchmark
|
||||||
|
|
||||||
|
Sprint exit criterion. Measures the complete latency path from server serialization to client scene update.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A benchmark script `tests/run-ipc-benchmark` that:
|
||||||
|
1. Starts the server subprocess
|
||||||
|
2. Waits for handshake (#555/#556)
|
||||||
|
3. Sends N `PlayerInput` messages (N = 100 by default)
|
||||||
|
4. Measures from `rmp_serde::to_vec` (server) to scene update completion (client)
|
||||||
|
5. Reports p50/p95/p99 latencies in milliseconds
|
||||||
|
6. Flags if any percentile exceeds 5ms threshold
|
||||||
|
- Output JSON: `{"p50_ms": 1.2, "p95_ms": 2.8, "p99_ms": 4.1, "threshold_ms": 5, "passed": true}`
|
||||||
|
- The benchmark is run as part of `tests/run-ipc-integration` in Layer 3
|
||||||
|
|
||||||
|
Implementation approach: server-side timestamps in `ObserverSnapshot` (add `server_emit_tick_ms` field, stripped in production builds), client records receive timestamp via `Time.get_ticks_msec()`. Delta = client receive - server emit.
|
||||||
|
|
||||||
|
Blocked by #555 and #556 — benchmark requires a working handshake before timing can start cleanly.
|
||||||
|
|
||||||
|
### #271 — IPC serialization fixture files
|
||||||
|
|
||||||
|
Layer 1 test data: pre-generated `.msgpack` fixture files that both Rust and GDScript can read to verify cross-language serialization compatibility.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A Rust binary (or test in `server/src/`) that generates fixtures to `tests/fixtures/`:
|
||||||
|
- `snapshot_minimal.msgpack` — minimal valid `ObserverSnapshot` (version=14, tick=0, one entity)
|
||||||
|
- `snapshot_full.msgpack` — all optional fields populated (monologue, dialogue, inventory, POIs, KG dump)
|
||||||
|
- `player_input_move.msgpack` — `PlayerInput { tick: 1, action: MoveNorth }`
|
||||||
|
- `player_input_interact.msgpack` — `PlayerInput { tick: 2, action: Interact { target: 99, verb: "Talk" } }`
|
||||||
|
- `malformed.msgpack` — intentionally truncated bytes (tests error handling)
|
||||||
|
- A GDScript test `client/tests/test_ipc_fixtures.gd` that reads each `.msgpack` fixture file, decodes via `Protocol.decode_snapshot()` / `Protocol.decode_player_input()`, and asserts expected field values
|
||||||
|
- Cross-language verification: the same byte stream decoded by both Rust and GDScript must produce identical field values
|
||||||
|
|
||||||
|
The fixture generator is run once (manually or in CI pre-step) to produce the committed `.msgpack` files. The files live at `tests/fixtures/` and are committed to the repo.
|
||||||
|
|
||||||
|
Blocked by #270 — fixture tests are invoked by `tests/run-ipc-fixtures`.
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#555 (server: protocol handshake) → #556 (ci: protocol handshake: client)
|
||||||
|
#555 + #556 → #342 (IPC benchmark: requires working handshake)
|
||||||
|
|
||||||
|
#270 (test runner scripts) → #271 (fixture files: invoked by run-ipc-fixtures)
|
||||||
|
|
||||||
|
Parallel starts: #270, #555 (server-side) — both unblocked week 1
|
||||||
|
#556 starts after #555 is at review
|
||||||
|
#271 starts after #270 merges
|
||||||
|
#342 starts after #555 + #556 both land
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): description" --description "body" --base main --head ci
|
||||||
|
```
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Sprint 19: Persist — Client Tasks
|
||||||
|
|
||||||
|
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||||
|
|
||||||
|
**Branch:** `client`
|
||||||
|
**Agents:** Stig (UI), Oscar (networking)
|
||||||
|
|
||||||
|
## Carry-over from Sprint 18
|
||||||
|
|
||||||
|
None. Sprint 18 closed clean.
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #554 | Save/load: client UI | #553 (server) |
|
||||||
|
| #258 | Game session management | — |
|
||||||
|
| #205 | GDScript test framework setup | — |
|
||||||
|
| #206 | Scene testing utilities | #205 |
|
||||||
|
| #348 | Debug visualization overlay | — |
|
||||||
|
|
||||||
|
Use `db/connectors/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack), D-085 (per-game save directory structure)
|
||||||
|
- `decisions/questions.md` — Q-029 (save file format design — open, Sprint 19 uses MessagePack quick-and-dirty format)
|
||||||
|
|
||||||
|
## Open Questions to Resolve Early
|
||||||
|
|
||||||
|
- **Q-029: Save file format design** — Sprint 19 ships MessagePack quick-and-dirty format. Do not over-engineer the loading screen metadata. A readable directory name (`<timestamp>-<seed>/`) per D-085 is sufficient for v0.1. The full versioning/migration design is tracked in Q-029 for a later sprint.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #554 — Save/load: client UI
|
||||||
|
|
||||||
|
Blocked by #553 (server must implement `SaveCommand`/`LoadCommand` IPC messages before client can wire F5/F6).
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- F5 key mapped in `client/scripts/autoloads/input_mapper.gd` to send a `SaveGame` IPC action to the server with the active game directory path (`user://saves/<game-id>/quicksave.sav`)
|
||||||
|
- F6 key mapped to send `LoadGame` IPC action with the same path
|
||||||
|
- Server responds with `SaveComplete`/`LoadComplete` — client shows a brief HUD notification ("Saved" / "Loading...")
|
||||||
|
- Loading screen scene: reads `user://saves/` directory, lists subdirectories sorted by last-modified (most recent first), shows most recent save filename per game directory per D-085
|
||||||
|
- F6 from the main menu opens the loading screen
|
||||||
|
- The active `game-id` is tracked in `GameState` autoload (add `current_game_id: String`)
|
||||||
|
|
||||||
|
Integration points: `client/scripts/autoloads/input_mapper.gd` (key bindings), `client/scripts/autoloads/game_state.gd` (current_game_id field), `client/scripts/protocol/` (new IPC message encoding), `client/scripts/ui/` (loading screen scene).
|
||||||
|
|
||||||
|
Save directory path per D-085: `user://saves/<timestamp>-<seed>/` where game-id is created on New Game (#258). F5 quicksave writes to `user://saves/<game-id>/quicksave.sav`. Loading screen lists directories sorted by `FileAccess.get_modified_time()`.
|
||||||
|
|
||||||
|
Wireframe reference: `docs/design/wireframes/menus/v01-save-load.png`.
|
||||||
|
|
||||||
|
### #258 — Game session management
|
||||||
|
|
||||||
|
New Game creates the per-game save directory before any save occurs (D-085 requirement: "directory created on New Game — even before the first save, so the path exists for quicksave/autosave").
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- `GameState.current_game_id: String` — format `<timestamp>-<seed>` (e.g. `20260225-143022-a7b3f1`)
|
||||||
|
- On "New Game": generate game-id (timestamp + RNG hex suffix), create `user://saves/<game-id>/` directory via `DirAccess.make_dir_recursive()`
|
||||||
|
- On "Continue" / loading screen selection: set `current_game_id` from the selected directory name
|
||||||
|
- "Quit to menu" flow: prompt "Save before quitting?" — F5 save if confirmed
|
||||||
|
- Wire the game-id into the `SimBridge` startup: server subprocess launched with `--game-id <id>` argument (or equivalent) so server can log with the same ID
|
||||||
|
|
||||||
|
Integration points: `client/scripts/autoloads/game_state.gd` (new fields), `client/scripts/protocol/server_process.gd` (subprocess launch args), `client/scripts/ui/` (main menu scene: New Game / Continue buttons).
|
||||||
|
|
||||||
|
Note: `game_state.gd` is already the largest autoload with 300+ lines. Keep game session logic in a thin wrapper on `GameState` — do not add another 100-line block directly. Consider a `session_manager.gd` helper if the logic exceeds 40 lines.
|
||||||
|
|
||||||
|
### #205 — GDScript test framework setup
|
||||||
|
|
||||||
|
The project has no GDScript test infrastructure yet. The Godot client has no equivalent of `cargo test`.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- Install and configure **GUT (Godot Unit Test)** as the GDScript test framework — it has the best Godot 4 support and is actively maintained
|
||||||
|
- Create `client/tests/` as the test root directory
|
||||||
|
- `client/tests/run_gut.gd`: the GUT runner script that CI can invoke headlessly (`godot --headless -s client/tests/run_gut.gd`)
|
||||||
|
- Exit code 0 = all pass, non-zero = failures — required for CI integration (#270 test runner scripts)
|
||||||
|
- A single smoke test `client/tests/test_protocol.gd`: verifies `Protocol.decode_snapshot(bytes)` returns non-null for a minimal valid msgpack fixture
|
||||||
|
|
||||||
|
GUT installation: add as a Godot addon. Check if there is already an `addons/` directory in `client/`.
|
||||||
|
|
||||||
|
### #206 — Scene testing utilities
|
||||||
|
|
||||||
|
Blocked by #205 (GUT must be installed first).
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- `client/tests/util/scene_helper.gd`: loads a scene file by path, instantiates it into a temporary viewport, provides `assert_node_exists(path)`, `assert_signal_emitted(node, signal_name)`, and `get_node_at(path)` helpers
|
||||||
|
- `client/tests/test_game_state.gd`: tests for `GameState.apply_snapshot()` — verify that a snapshot dictionary with known fields updates the correct `GameState` fields
|
||||||
|
- `client/tests/test_protocol.gd` (extend from #205 smoke test): add roundtrip test for `Protocol.encode_player_input()` and `Protocol.decode_player_input()`
|
||||||
|
|
||||||
|
These utilities are the scaffolding for all future client tests. Keep them minimal and dependency-free — do not require a running server.
|
||||||
|
|
||||||
|
### #348 — Debug visualization overlay
|
||||||
|
|
||||||
|
Dev tool (F3 toggle). The stub `client/scripts/ui/debug_overlay.gd` already exists.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- Extend `debug_overlay.gd` to draw on a `CanvasLayer` above the game world:
|
||||||
|
- **Pathfinding waypoints**: draw lines between waypoint positions from `GameState.visible_entities` (entities with kind `Npc` — estimate waypoints from position delta between ticks)
|
||||||
|
- **Line-of-sight rays**: draw lines from player position to each visible entity
|
||||||
|
- **Vision cone boundary**: draw the forward/peripheral arc boundary using `GameState.visibility_sectors`
|
||||||
|
- **Information state tags**: draw confidence label (Suspects/KnowsOf/KnowsDetails/Direct) above each visible NPC from `GameState.player_knowledge`
|
||||||
|
- **Tick timing graph**: small line chart in corner showing tick delta over the last 30 ticks
|
||||||
|
- F3 toggle: connected to `InputMapper` action `toggle_debug_overlay`
|
||||||
|
- Debug overlay is **dev-only**: compiled out in export builds via `OS.is_debug_build()` check
|
||||||
|
|
||||||
|
Integration points: `client/scripts/autoloads/game_state.gd` (data source), `client/scripts/autoloads/input_mapper.gd` (F3 action), `client/scripts/ui/debug_overlay.gd` (extend existing stub).
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#205 (GDScript test framework) → #206 (scene testing utilities)
|
||||||
|
|
||||||
|
#258 (game session management) → #554 (save/load client UI: needs current_game_id)
|
||||||
|
#553 (server, ECS extraction) → #554 (save/load client UI: needs IPC commands)
|
||||||
|
|
||||||
|
#348 (debug overlay) → standalone, parallel track
|
||||||
|
```
|
||||||
|
|
||||||
|
Parallel starts: #258, #205, #348 all unblocked week 1.
|
||||||
|
#554 starts after #553 (server) reaches review stage and #258 lands.
|
||||||
|
#206 starts after #205 merges.
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
|
||||||
|
```
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Sprint 19: Persist — Joint Briefing
|
||||||
|
|
||||||
|
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||||
|
|
||||||
|
**Sprint:** 19
|
||||||
|
**Status:** planning → active
|
||||||
|
|
||||||
|
## Pre-Sprint
|
||||||
|
|
||||||
|
Before implementation begins, no schema work is needed — `SaveStateV1` is already defined (#256, done). However, the following IPC protocol additions must be agreed between server and client **before either side implements**:
|
||||||
|
|
||||||
|
| Item | Owner | Needed by |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| `HandshakeMessage` wire format | server (#555) | client (#556) |
|
||||||
|
| `SaveCommand` / `LoadCommand` IPC message variants | server (#553) | client (#554) |
|
||||||
|
| `SaveComplete` / `LoadComplete` response format | server (#553) | client (#554) |
|
||||||
|
| Fixture file format and field names | ci (#271) | all teams |
|
||||||
|
|
||||||
|
Server team: define these in `server/src/bridge/types.rs` first (as Rust structs + serde). CI team + client team: implement against the published definitions. Do not start #556 or #554 until #555 and #553 respectively reach review.
|
||||||
|
|
||||||
|
## Team Allocation
|
||||||
|
|
||||||
|
| Team | Tickets | Count |
|
||||||
|
|------|---------|-------|
|
||||||
|
| server | #553, #96, #97, #98, #200, #272, #555 | 7 |
|
||||||
|
| client | #554, #258, #205, #206, #348 | 5 |
|
||||||
|
| ci | #270, #556, #342, #271 | 4 |
|
||||||
|
|
||||||
|
## Cross-Team Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
server #555 (handshake: server)
|
||||||
|
→ ci #556 (handshake: client)
|
||||||
|
→ ci #342 (IPC benchmark)
|
||||||
|
|
||||||
|
server #553 (ECS extraction)
|
||||||
|
→ client #554 (save/load UI)
|
||||||
|
|
||||||
|
server #200 (test module org)
|
||||||
|
→ server #272 (info boundary tests)
|
||||||
|
|
||||||
|
client #205 (GDScript test framework)
|
||||||
|
→ client #206 (scene testing utilities)
|
||||||
|
→ ci #271 (fixture files need GDScript reader)
|
||||||
|
|
||||||
|
ci #270 (test runner scripts)
|
||||||
|
→ ci #271 (fixture tests invoked by run-ipc-fixtures)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sprint Completion Proof
|
||||||
|
|
||||||
|
When Sprint 19 is done, the following must all be observable:
|
||||||
|
|
||||||
|
1. **Save/load round-trip**: Press F5 in-game → file appears at `user://saves/<game-id>/quicksave.sav` in MessagePack format. Press F6 → game state restored from file (tick, entities, player knowledge match pre-save state).
|
||||||
|
|
||||||
|
2. **Per-game directory**: Starting a New Game creates `user://saves/<timestamp>-<seed>/` before any save occurs. The loading screen lists this directory.
|
||||||
|
|
||||||
|
3. **Tier eviction**: Spawn 90+ NPCs (above Active cap of 80). `ActiveSim` count stabilizes at ≤80 with the excess evicted to `BackgroundSim`/`StateSaved`. Scope-tagged NPCs (KnownContact, Colleague) remain Active regardless.
|
||||||
|
|
||||||
|
4. **Protocol handshake**: Starting the server subprocess: first IPC message is a `HandshakeMessage`. Version mismatch (force by temporarily changing server `PROTOCOL_VERSION`) produces an error and clean shutdown — no crash.
|
||||||
|
|
||||||
|
5. **Test infrastructure**: `tests/run-all` exits 0 with all suites passing. `tests/run-ipc-fixtures` reads committed `.msgpack` files and validates both Rust and GDScript decode them identically. `cargo test` in `server/` includes information boundary negative tests that assert absence of leakage.
|
||||||
|
|
||||||
|
6. **Debug overlay**: F3 in-game toggles the debug canvas showing vision cone arcs, entity LOS rays, NPC knowledge confidence labels, and tick timing graph.
|
||||||
|
|
||||||
|
## Test Plan (D-030)
|
||||||
|
|
||||||
|
| Layer | Runner | Tickets | When |
|
||||||
|
|-------|--------|---------|------|
|
||||||
|
| Layer 1: Fixture serialization | `tests/run-ipc-fixtures` | #271, #200 | Every edit |
|
||||||
|
| Layer 1: Unit tests (Rust) | `tests/run-rust` | #272, #96, #97, #98 | Every edit |
|
||||||
|
| Layer 1: Unit tests (GDScript) | `tests/run-godot` | #205, #206 | Every edit |
|
||||||
|
| Layer 2: Mock protocol | `tests/run-ipc-protocol` | #555, #556 | Every PR |
|
||||||
|
| Layer 3: Real subprocess | `tests/run-ipc-integration` | #342, #553/#554 | Daily/pre-merge |
|
||||||
|
|
||||||
|
All layers must pass before any PR merges. `make ci` invokes `tests/run-all`.
|
||||||
|
|
||||||
|
## Key Decisions Reference
|
||||||
|
|
||||||
|
| Decision | Domain file | Relevant to |
|
||||||
|
|----------|------------|-------------|
|
||||||
|
| D-010: Determinism + info boundaries | architecture.md | #272, #96, #553 |
|
||||||
|
| D-020: IPC architecture, MessagePack | architecture.md | #553, #554, #555, #556, #342, #271 |
|
||||||
|
| D-026: Simulation tiers, timestamp eviction, scope tags | architecture.md | #96, #97, #98 |
|
||||||
|
| D-030: Three-layer test architecture | architecture.md | #200, #270, #271, #272, #342 |
|
||||||
|
| D-085: Per-game save directory structure | architecture.md | #554, #258, #553 |
|
||||||
|
| Q-029: Save file format (open) | questions.md | #553 (quick-and-dirty MessagePack for now) |
|
||||||
|
|
||||||
|
## Risk Register
|
||||||
|
|
||||||
|
| Risk | Probability | Impact | Mitigation |
|
||||||
|
|------|------------|--------|------------|
|
||||||
|
| ECS extraction misses components (#553) | Medium | High | #272 info boundary tests catch leakage; fixture roundtrip (#271) catches missing fields |
|
||||||
|
| IPC protocol mismatch between #555 and #556 | Low | High | Define wire types in Rust first, share definition doc before client implements |
|
||||||
|
| GUT framework incompatible with Godot 4.x version in use (#205) | Low | Medium | Verify GUT version before full installation; fallback to hand-rolled test runner |
|
||||||
|
| Save file bloat (SaveStateV1 larger than ~1-2 KB/NPC) | Low | Low | Q-029 tracks compression — deferred. Profile with #342 benchmark if flagged |
|
||||||
|
| Scope tag assignment races with eviction (#97/#98) | Low | Medium | Eviction runs after scope tag system in schedule order; schedule ordering test in #97 |
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Sprint 19: Persist — Server Tasks
|
||||||
|
|
||||||
|
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||||
|
|
||||||
|
**Branch:** `server`
|
||||||
|
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
|
||||||
|
|
||||||
|
## Carry-over from Sprint 18
|
||||||
|
|
||||||
|
None. Sprint 18 closed clean.
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #553 | Save/load: server ECS extraction | #256 (done) |
|
||||||
|
| #96 | State serialization system | — |
|
||||||
|
| #97 | Timestamp-based eviction | — |
|
||||||
|
| #98 | Scope tag system | — |
|
||||||
|
| #200 | Test module organization | — |
|
||||||
|
| #272 | Information boundary negative test suite | #200 |
|
||||||
|
| #555 | Protocol version handshake: server | — |
|
||||||
|
|
||||||
|
Use `db/connectors/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/architecture.md` — D-010 (determinism + info boundaries), D-020 (IPC architecture, MessagePack), D-026 (simulation tiers: Active/Background/State-saved/Ungenerated, timestamp eviction, scope tags), D-030 (three-layer test architecture), D-041 (knowledge graph data model, StableId)
|
||||||
|
- `decisions/questions.md` — Q-029 (save file format design — Sprint 19 ships quick-and-dirty, Q-029 tracks the thorough design pass for later)
|
||||||
|
|
||||||
|
## Open Questions to Resolve Early
|
||||||
|
|
||||||
|
- **Q-029: Save file format design** — Sprint 19 uses MessagePack from `SaveStateV1`. Resolution of full versioning/migration strategy is deferred. Do not block #553 on Q-029; proceed with MessagePack format as specified.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #553 — Save/load: server ECS extraction
|
||||||
|
|
||||||
|
`server/src/simulation/save_state.rs` already defines `SaveStateV1` (done in #256). The data model is complete: tick, seed, RNG, `player_knowledge: KnowledgeGraph`, `relationship_graph: RelationshipGraph`, `npc_states: Vec<NpcSaveState>`. Roundtrip tests pass.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A `save_to_file(path: &Path, world: &World) -> Result<()>` function: queries ECS for all relevant components, builds a `SaveStateV1`, calls `state.to_bytes()`, writes to disk. Per-game directory path is provided by the client via a new `IpcCommand::SaveGame { path: String }` variant.
|
||||||
|
- A `load_from_file(path: &Path, world: &mut World) -> Result<()>` function: reads bytes, calls `SaveStateV1::from_bytes`, re-spawns entities, injects `KnowledgeGraph`, `RelationshipGraph`, and `SimulationTime` as resources, reseeds the RNG.
|
||||||
|
- A `SaveCommand` and `LoadCommand` IPC message pair wired through `server/src/bridge/` — server receives save/load triggers from the client, executes, sends `SaveComplete`/`LoadComplete` response.
|
||||||
|
- Format version check on load: reject files with `format_version != SAVE_FORMAT_VERSION` with a clear error.
|
||||||
|
|
||||||
|
Integration points: `server/src/simulation/save_state.rs` (data model), `server/src/bridge/types.rs` (new IPC commands), `server/src/bridge/local.rs` or `tcp.rs` (command dispatch), `server/src/knowledge/graph.rs` (KG re-injection), `server/src/npc/relationships.rs` (RelationshipGraph re-injection).
|
||||||
|
|
||||||
|
Gotcha: ECS entity IDs are generational — do not save bevy `Entity` handles. `SaveStateV1` already uses `StableId(u64)` throughout. On load, re-spawn entities and re-register `StableId -> Entity` in `EntityRegistry`.
|
||||||
|
|
||||||
|
### #96 — State serialization system
|
||||||
|
|
||||||
|
Complement to #553. Where #553 handles whole-game ECS extraction, #96 implements the per-NPC serialization primitive for tier transitions.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A `serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState` function producing the frozen struct (~1-2 KB per NPC per D-026)
|
||||||
|
- A `deserialize_npc_from_frozen(state: &NpcSaveState, commands: &mut Commands)` that re-spawns a full NPC entity with the correct component set
|
||||||
|
- Used by the tier system when evicting to `StateSaved`: instead of keeping ECS components live, serialize to `NpcSaveState` and despawn. On reactivation: deserialize and re-spawn.
|
||||||
|
- Unit tests: serialize + deserialize produces an entity with identical component values
|
||||||
|
|
||||||
|
Existing shape: `NpcSaveState` in `save_state.rs` captures position, `SecretSeverity`, `Relationships`, stress, tolerance, contentment, and optional `KnowledgeGraph`. Verify this covers all components needed for Background/Active reconstruction. Flag any missing axis (D-024) in a code comment for follow-up.
|
||||||
|
|
||||||
|
### #97 — Timestamp-based eviction
|
||||||
|
|
||||||
|
`server/src/simulation/tier.rs` has the tier marker components (`ActiveSim`, `BackgroundSim`, `StateSaved`) and the distance-based `update_tier_markers` system. What is missing: the LRU eviction when sim-space fills up.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A `LastInteractionTick(u64)` component on all NPCs, updated whenever the player interacts with or observes an NPC
|
||||||
|
- A `SimSpacePressure` resource tracking current `ActiveSim` count vs. capacity (cap: 80 per D-026)
|
||||||
|
- An `evict_excess_active` system: when `ActiveSim` count exceeds capacity, demote the N oldest-by-`LastInteractionTick` entities to `BackgroundSim` (or `StateSaved` if beyond background radius)
|
||||||
|
- Uses a priority queue (BinaryHeap keyed by `LastInteractionTick`) for O(log N) eviction selection
|
||||||
|
|
||||||
|
Gotcha: eviction must not demote entities with active scope tags (see #98). The eviction system runs after #98's `ScopeTag` check.
|
||||||
|
|
||||||
|
### #98 — Scope tag system
|
||||||
|
|
||||||
|
Scope tags are the mechanism by which certain NPCs stay pinned to `ActiveSim` regardless of distance or LRU pressure (D-026: "neighborhood, active-quest, colleague, known-contact").
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A `ScopeTag` component (or enum-tagged component) with variants: `Neighborhood`, `ActiveQuest`, `Colleague`, `KnownContact`
|
||||||
|
- A `ScopePinned` marker component: attached to any NPC carrying a `ScopeTag`, removed when no scope tags remain
|
||||||
|
- The eviction system (#97) skips entities with `ScopePinned`
|
||||||
|
- Scope tags are assigned by gameplay systems: `Neighborhood` from proximity at session start, `KnownContact` from `KnowledgeGraph` entries with confidence >= `KnowsOf`, `Colleague` from `RelationshipGraph` edges with `Friend` or `Colleague` kind, `ActiveQuest` reserved for future quest system
|
||||||
|
|
||||||
|
Integration: `server/src/simulation/tier.rs` (eviction exclusion), `server/src/knowledge/graph.rs` (KnownContact assignment trigger), `server/src/npc/relationships.rs` (Colleague assignment trigger).
|
||||||
|
|
||||||
|
### #200 — Test module organization
|
||||||
|
|
||||||
|
`server/src/test_world/` already exists with `constants.rs`, `invariants.rs`, `mod.rs`, `reset.rs`, and `rooms/`. This is the foundation.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- Establish the external test module pattern for the server crate: `#[cfg(test)] mod tests` in each module, plus a top-level `tests/` directory alongside `src/` for integration tests that run against the full simulation
|
||||||
|
- Document the three-layer test architecture (D-030): Layer 1 = fixture-based serialization (fast), Layer 2 = mock subprocess protocol state machine (medium), Layer 3 = real subprocess integration (slow)
|
||||||
|
- Create `tests/integration/mod.rs` as the entry point for Layer 3 tests
|
||||||
|
- Ensure `cargo test` in `server/` runs all layers correctly
|
||||||
|
- No-ops are fine for Layer 2 and 3 stubs — the important deliverable is the directory structure and entry points
|
||||||
|
|
||||||
|
#272 is blocked by this ticket — the information boundary tests land in the new structure.
|
||||||
|
|
||||||
|
### #272 — Information boundary negative test suite
|
||||||
|
|
||||||
|
The core asymmetric information claim of the game: entity X cannot see what entity Y knows, unless the observation system explicitly grants it.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- A suite of negative tests asserting that information does NOT cross boundaries:
|
||||||
|
1. Player's `KnowledgeGraph` does not contain NPC data that was not observed (no passive leakage)
|
||||||
|
2. `ObserverSnapshot` for the player does not include entities outside LOS (fog of perception holds)
|
||||||
|
3. Background-tier NPC `KnowledgeGraph` is not updated by Active-tier systems (tier boundary holds)
|
||||||
|
4. `SaveStateV1` for one NPC does not serialize another NPC's `KnowledgeGraph`
|
||||||
|
- Uses `test_world/` for scenario setup — reuse existing helpers
|
||||||
|
- These tests live in Layer 1 (pure unit) and Layer 2 (mock world) of D-030
|
||||||
|
|
||||||
|
Gotcha: "negative tests" means asserting absence. Use `assert!(kg.entities.get(&id).is_none())` patterns — not just "test passed because nothing happened."
|
||||||
|
|
||||||
|
### #555 — Protocol version handshake: server
|
||||||
|
|
||||||
|
`server/src/bridge/types.rs` defines `PROTOCOL_VERSION: u8 = 14`. The version is already included in `ObserverSnapshot` as `pub version: u8`.
|
||||||
|
|
||||||
|
What this ticket must deliver:
|
||||||
|
- Verify the first `ObserverSnapshot` emitted after subprocess startup includes `version: PROTOCOL_VERSION`
|
||||||
|
- Add a handshake phase: before normal tick loop begins, server emits a minimal `HandshakeMessage { protocol_version: PROTOCOL_VERSION }` as the very first framed message on the IPC channel
|
||||||
|
- Client reads this message and validates before sending any `PlayerInput`
|
||||||
|
- If the server receives a `PlayerInput` before completing handshake, log a warning and process normally (forward-compatible)
|
||||||
|
- Integration point: `server/src/bridge/local.rs` (startup sequence), `server/src/bridge/framing.rs` (message framing)
|
||||||
|
|
||||||
|
Coordinate with ci team (#556) — the client-side validation is their ticket.
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#555 (protocol handshake: server) → #556 (ci: protocol handshake: client)
|
||||||
|
|
||||||
|
#200 (test module organization) → #272 (information boundary tests)
|
||||||
|
|
||||||
|
#98 (scope tag system) → feeds into #97 (eviction respects scope pins)
|
||||||
|
|
||||||
|
#256 (done: save state data model) → #553 (server ECS extraction)
|
||||||
|
#553 (server ECS extraction) → #554 (client: save/load UI)
|
||||||
|
|
||||||
|
#96 (state serialization) → feeds into #553 (used during ECS extraction)
|
||||||
|
|
||||||
|
Parallel starts: #555, #97, #98, #96, #200 — all unblocked week 1
|
||||||
|
#553 starts after #96 is at review stage (needs serialize_npc_to_frozen)
|
||||||
|
#272 starts after #200 merges
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
|
||||||
|
```
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# Workshop: Character Creation & Game Setup
|
||||||
|
|
||||||
|
**Date:** 2026-02-25
|
||||||
|
**Facilitator:** Team Leader (Jeroen)
|
||||||
|
**Participants:** Nigel (replayability), Paula (narrative), Gestalt (systems), Miri (worldbuilding), Tyre (architecture), Qatux (documenter)
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define what the character creation / new game screen actually does. This
|
||||||
|
is the most fundamental unresolved design question in the project: what
|
||||||
|
does the player choose, what does the seed control, and what does that
|
||||||
|
combination produce?
|
||||||
|
|
||||||
|
This workshop resolves **Q-011** (character selection and playable
|
||||||
|
characters) and establishes the boundary between player agency, character
|
||||||
|
archetype, and world seed. Everything downstream — quest generation, gate
|
||||||
|
activation, difficulty, replayability — flows from getting this table right.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### What's decided
|
||||||
|
|
||||||
|
- **D-005:** Single character per playthrough. Character choice determines
|
||||||
|
starting location, starting knowledge, available levers, personal goals.
|
||||||
|
"Same conspiracy, different character, completely different game."
|
||||||
|
- **D-027:** v0.1 vertical slice ships with 2 characters: smuggler and
|
||||||
|
detective. Inverted perspectives on the same world.
|
||||||
|
- **D-023:** Three-tier content model. Tier 1 (authored drama) drawn from
|
||||||
|
a pool at game start. Tier 2 (templated). Tier 3 (procedural filler).
|
||||||
|
- **D-029:** Population entanglement ratio 30/50/20, varies per seed.
|
||||||
|
- **D-010:** Deterministic simulation. Same seed + same content = identical
|
||||||
|
world state.
|
||||||
|
- **D-039:** Wow moment 4 is the Divergence Reveal — same room, different
|
||||||
|
character, different everything. This is THE replayability payoff.
|
||||||
|
- **D-041:** Knowledge graph is per-entity. Characters start with different
|
||||||
|
knowledge.
|
||||||
|
- **D-032:** Separate monologue pools per character.
|
||||||
|
- **Seed config schema** (ticket #394): Records seed value, character
|
||||||
|
selection, pool draws, template assignments, starting knowledge.
|
||||||
|
|
||||||
|
### What's open
|
||||||
|
|
||||||
|
- **Q-011:** Character selection and playable characters — not yet discussed.
|
||||||
|
Which characters? How different are starting positions? Canon or original?
|
||||||
|
- **Q-010:** Storyteller AI design — pacing rules, structural vs dramatic
|
||||||
|
randomness. Adjacent to this workshop but NOT in scope to fully resolve.
|
||||||
|
|
||||||
|
### What's NOT in scope
|
||||||
|
|
||||||
|
- Full character roster beyond v0.1 (two characters first, prove it works)
|
||||||
|
- Endgame quest design (no endgame exists yet — note toggles, defer design)
|
||||||
|
- Storyteller pacing algorithm (Q-010 is a separate workshop)
|
||||||
|
- Character naming edge cases (namespace collisions with NPCs — note, defer)
|
||||||
|
- Full quest system architecture (scope to: what quest SHAPES are seeded at
|
||||||
|
creation, not the full quest pipeline)
|
||||||
|
|
||||||
|
### Design guardrail
|
||||||
|
|
||||||
|
Character selection determines your starting **information position** and
|
||||||
|
**social graph**, not your capabilities. Characters have different verbs
|
||||||
|
available, not different success probabilities for the same verbs. The
|
||||||
|
smuggler doesn't get "+5% to cargo inspection" — the smuggler gets "Slip
|
||||||
|
Manifest" as a verb the detective never sees. This is D-005's intent and
|
||||||
|
D-028's access tier system in practice. Do not drift toward stat sheets.
|
||||||
|
|
||||||
|
## Key Questions
|
||||||
|
|
||||||
|
### Q1: What does character selection actually select?
|
||||||
|
|
||||||
|
Three possible models — the workshop must pick one (or a hybrid):
|
||||||
|
|
||||||
|
**A. Fixed archetype roster.** Player picks "smuggler" or "detective" from a
|
||||||
|
list. Each is a fully pre-authored starting position. Knowledge from
|
||||||
|
playthrough 1 fully transfers — you know exactly where the smuggler starts.
|
||||||
|
|
||||||
|
**B. Archetype + generated instance.** Player picks "smuggler" but YOUR
|
||||||
|
smuggler is procedurally placed in the social web. Different starting
|
||||||
|
coworkers, different shift schedule, different corridor assignment. Knowledge
|
||||||
|
partially transfers — you know smuggler life, but not this smuggler's life.
|
||||||
|
|
||||||
|
**B2. Archetype + curated instance.** Player picks "smuggler," sees 2-3
|
||||||
|
procedurally generated social configurations, and picks one. RimWorld's
|
||||||
|
colonist reroll mechanic — curation from a generated pool rather than full
|
||||||
|
specification. Middle path between A and B.
|
||||||
|
|
||||||
|
**C. Fully custom.** Player picks background axes (profession, social
|
||||||
|
tier, faction affinity). No pre-authored archetype. Maximum variation,
|
||||||
|
maximum authoring cost.
|
||||||
|
|
||||||
|
For each model: what are the replayability implications? What's the
|
||||||
|
authoring cost? What does this mean for the Divergence Reveal (D-039)?
|
||||||
|
|
||||||
|
Also consider: does the player configure starting knowledge weights, or
|
||||||
|
is starting knowledge fully determined by archetype? (DF's embark skill
|
||||||
|
point system lets players shape starting capability within constraints.)
|
||||||
|
|
||||||
|
### Q2: The seed boundary — what varies by what?
|
||||||
|
|
||||||
|
Produce a canonical three-column table:
|
||||||
|
|
||||||
|
| Determined by world seed | Determined by character choice | Player-configured |
|
||||||
|
|--------------------------|-------------------------------|-------------------|
|
||||||
|
| ? | ? | ? |
|
||||||
|
|
||||||
|
Examples to place: NPC relationships, which NPCs are compromised,
|
||||||
|
starting location, starting knowledge graph state, access tiers,
|
||||||
|
gate activation timing, conspiracy shape, population entanglement
|
||||||
|
ratio, THE FRIEND identity, starting inventory...
|
||||||
|
|
||||||
|
Include a fourth implicit column: **what information exists in the world
|
||||||
|
but is inaccessible to this character?** Not locked behind a mechanic —
|
||||||
|
just absent from their information space entirely. The gap between what
|
||||||
|
the world contains and what the character can see IS the replayability.
|
||||||
|
(See: Obra Dinn in Reference Games.)
|
||||||
|
|
||||||
|
This table IS the workshop's primary deliverable. Get it right and every
|
||||||
|
downstream system knows its inputs.
|
||||||
|
|
||||||
|
### Q3: How does gate activation relate to character choice?
|
||||||
|
|
||||||
|
The contamination/conspiracy discovery trigger — the moment the game shifts
|
||||||
|
from daily life to investigation. Two sub-questions:
|
||||||
|
|
||||||
|
**A.** Does character choice change WHEN you discover contamination, or only
|
||||||
|
HOW you experience it? (Detective flags cargo anomalies early via lattice
|
||||||
|
analysis. Smuggler witnesses something directly. Same world-state, different
|
||||||
|
discovery paths.)
|
||||||
|
|
||||||
|
Consider Pentiment's model: the murder happens near you, not TO you. You're
|
||||||
|
pulled in by proximity and relationship, not by being the assigned
|
||||||
|
investigator. Is gate activation something that happens to the world (and
|
||||||
|
the character stumbles into it), or something the character triggers through
|
||||||
|
their specific access?
|
||||||
|
|
||||||
|
**B.** Are discovery paths authored per character (detective always discovers
|
||||||
|
via X) or emergent (character knowledge graph + storyteller pacing = different
|
||||||
|
discovery window per playthrough)? What's the minimum authored content needed
|
||||||
|
per character to make gate activation feel character-specific?
|
||||||
|
|
||||||
|
Explicit RimWorld check: is contamination timing a player-configurable
|
||||||
|
"storyteller" choice, fixed per character, or emergent from play? Don't
|
||||||
|
collapse pacing control into archetype selection.
|
||||||
|
|
||||||
|
### Q4: Quest seeding — templates vs randomization
|
||||||
|
|
||||||
|
The user's directive: "the quest system should offer relevant randomized
|
||||||
|
quests based on creation instead of going fully scripted." Scope this to:
|
||||||
|
|
||||||
|
- What quest SHAPES (not specific quests) are determined at character
|
||||||
|
creation? (e.g., smuggler gets logistics-flavored side quests, detective
|
||||||
|
gets investigation-flavored ones)
|
||||||
|
- How do Tier 2 templates (D-023) interact with character choice? Does the
|
||||||
|
smuggler's template pool differ from the detective's?
|
||||||
|
- What is authored (main quest templates, scripted quality) vs generated
|
||||||
|
(side content, character-relevant variations)?
|
||||||
|
- How many quest templates are needed per character for v0.1 to feel varied?
|
||||||
|
- Does the character have visible long-term goals the game tracks? Does the
|
||||||
|
smuggler TELL you what they want (goals screen) or do wants emerge from
|
||||||
|
play? (The Sims' wants/aspirations system as reference.)
|
||||||
|
|
||||||
|
### Q5: Game conditions and toggles
|
||||||
|
|
||||||
|
The user wants players to be able to configure their experience. But some
|
||||||
|
toggles destroy the game's core tension. Define:
|
||||||
|
|
||||||
|
- **What CAN be toggled:** Challenge intensity, optional content modules,
|
||||||
|
timer pressure, specific life-sim subsystems
|
||||||
|
- **What CANNOT be toggled:** Core conspiracy simulation, investigator
|
||||||
|
faction presence, information asymmetry. These exist whether the player
|
||||||
|
sees them or not.
|
||||||
|
- **Starting location selection:** Is this a player choice or determined
|
||||||
|
by archetype? If player choice, what does it mean for authored content?
|
||||||
|
- **Enable/disable endgame quests:** Note for future design. What's the
|
||||||
|
minimum we need to decide NOW vs what can wait until endgame exists?
|
||||||
|
|
||||||
|
### Q6: The playthrough 2 test
|
||||||
|
|
||||||
|
Concrete synthesis test. Nigel presents the following scenario to the room:
|
||||||
|
|
||||||
|
> You've played the smuggler on seed X. You now know: Kael is trying to
|
||||||
|
> exit the ring. Sera Venn is protecting Naia. The detective flagged your
|
||||||
|
> manifest on Day 2. The contamination hit during the evening shift at
|
||||||
|
> The Last Shift. You pick the detective on the SAME seed. Minute 1: you
|
||||||
|
> arrive at the Commission office. Minute 5: your first assignment.
|
||||||
|
> Minute 10: you walk into The Terminal where you spent 30 hours as the
|
||||||
|
> smuggler.
|
||||||
|
|
||||||
|
Against the combined design from Rounds 1-4, each participant answers:
|
||||||
|
|
||||||
|
1. What does the detective see in The Terminal that the smuggler never saw?
|
||||||
|
2. What does the detective's monologue say about Kael — whom the smuggler
|
||||||
|
considered a friend?
|
||||||
|
3. Does the contamination trigger differently, or at the same moment via
|
||||||
|
a different path?
|
||||||
|
4. Name one thing the player LEARNED in playthrough 1 that changes how
|
||||||
|
they PLAY playthrough 2 — not metagaming, but genuine new understanding.
|
||||||
|
|
||||||
|
If participants can't answer these concretely, the design has a gap.
|
||||||
|
Find it and fix it before the workshop closes.
|
||||||
|
|
||||||
|
## Reference Games
|
||||||
|
|
||||||
|
### Dwarf Fortress — the fossil record
|
||||||
|
|
||||||
|
World generation creates centuries of invisible history. The player never
|
||||||
|
reads a history log — they excavate its consequences. A collapsed
|
||||||
|
civilization left ruins. A grudge between two species shapes who attacks
|
||||||
|
your fort. The history is SUBSTRATE, not content.
|
||||||
|
|
||||||
|
The lesson for us: the world seed should produce CONSTRAINTS and RESIDUES
|
||||||
|
that make the current situation feel inevitable. How long has this smuggling
|
||||||
|
ring been operating? What's its history of near-discovery? Which institutional
|
||||||
|
figures already have kompromat on them? The player never sees this directly
|
||||||
|
but feels its weight on every NPC relationship state they encounter.
|
||||||
|
|
||||||
|
Also relevant: the embark skill point system — players shape starting
|
||||||
|
capability within constraints rather than receiving a fixed loadout. Consider
|
||||||
|
for Q1: does the player configure starting knowledge within their archetype?
|
||||||
|
|
||||||
|
### RimWorld — the separation principle
|
||||||
|
|
||||||
|
Storyteller selection (Cassandra/Phoebe/Randy) controls pacing, not content.
|
||||||
|
Scenario defines starting resources and constraints. Colonist generation is
|
||||||
|
partially random, partially player-curated (reroll, choose skills).
|
||||||
|
|
||||||
|
**Key lesson 1:** The storyteller and starting conditions are SEPARATE
|
||||||
|
choices. Our gate activation / contamination pacing (Q3) maps to storyteller
|
||||||
|
selection. Our character archetype maps to scenario. Don't collapse them.
|
||||||
|
|
||||||
|
**Key lesson 2:** Player CURATION from a procedurally generated set is
|
||||||
|
different from player SPECIFICATION of a custom set. RimWorld's colonist
|
||||||
|
reroll is a point-buy system hidden behind a reroll interface. For us:
|
||||||
|
model B2 — see 2-3 generated social configurations for "your smuggler" and
|
||||||
|
pick one. This is a viable middle path that deserves to be on the table.
|
||||||
|
|
||||||
|
### The Sims — verbs, not stats
|
||||||
|
|
||||||
|
Traits in The Sims are PERMISSION SYSTEMS for social interactions, not stat
|
||||||
|
modifiers. The Outgoing Sim doesn't get +20% to social checks — they get
|
||||||
|
access to DIFFERENT VERBS. They can autonomously initiate conversations the
|
||||||
|
Introvert Sim cannot. This is exactly our access tier system (D-028 Layer 1).
|
||||||
|
|
||||||
|
**Key lesson 1:** Character creation changes which verbs you have, not how
|
||||||
|
well you perform shared verbs. The smuggler gets "Slip Manifest." The
|
||||||
|
detective gets "Pull Records." Neither is better — they're different
|
||||||
|
information-gathering tools for the same world.
|
||||||
|
|
||||||
|
**Key lesson 2:** Neighborhood placement as replayability driver. The lot
|
||||||
|
you choose positions you relative to neighbor NPCs, which determines which
|
||||||
|
relationships bootstrap organically through proximity. Early relationships
|
||||||
|
form through proximity, not player initiative. The smuggler starts embedded
|
||||||
|
in The Terminal — relationships with ring members bootstrap before the
|
||||||
|
player does anything. The detective starts at the Commission — different
|
||||||
|
organic relationships form. THIS is the structural driver that makes the
|
||||||
|
same seed play differently.
|
||||||
|
|
||||||
|
### Disco Elysium — observation filters, not capabilities
|
||||||
|
|
||||||
|
D-005 already cites Disco Elysium as a design reference. The Thought
|
||||||
|
Cabinet is a permission system for new dialogue options and monologue lines.
|
||||||
|
Building a character in DE doesn't give you stats — it gives you access to
|
||||||
|
different OBSERVATIONS of the same world. Intellect doesn't make you
|
||||||
|
smarter — it makes your character say different things to themselves when
|
||||||
|
they see the same evidence.
|
||||||
|
|
||||||
|
**Key lesson:** Character build determines what your character NOTICES, not
|
||||||
|
what they can DO. This is D-032 (separate monologue pools) and D-041
|
||||||
|
(per-entity knowledge graph) in one reference. If participants are thinking
|
||||||
|
about Q1 models without DE on the table, they'll drift toward stat-system
|
||||||
|
thinking. DE keeps them on the observation-filter track.
|
||||||
|
|
||||||
|
### Return of the Obra Dinn — information gap as presence
|
||||||
|
|
||||||
|
Obra Dinn demonstrates that information asymmetry can be the ENTIRE game.
|
||||||
|
You piece together events from fragments. The information gap feels like
|
||||||
|
presence, not absence — you FEEL the weight of what you can't see yet.
|
||||||
|
|
||||||
|
**Key lesson for Q2:** The seed boundary table needs to account for what
|
||||||
|
exists in the world but is invisible to this character. Not locked behind a
|
||||||
|
mechanic — just absent from their information space. Character A's world
|
||||||
|
contains things that are simply not in Character B's world. That gap is
|
||||||
|
the pull that drives playthrough 2.
|
||||||
|
|
||||||
|
### Pentiment — gate activation by proximity
|
||||||
|
|
||||||
|
Pentiment commits to a single-character perspective. The gate activation
|
||||||
|
question it answers: "what triggers the player from daily life into
|
||||||
|
investigation?" A murder happens near you, not TO you. You're pulled in
|
||||||
|
by proximity and relationship, not by institutional assignment. The player
|
||||||
|
character is NOT the assigned detective — they're a witness with skills.
|
||||||
|
|
||||||
|
**Key lesson for Q3:** Same discovery timing, but character-dependent tools
|
||||||
|
for responding to it. The contamination doesn't care who you are — it
|
||||||
|
happens. But your character's position determines whether you see it as
|
||||||
|
threat, opportunity, or puzzle.
|
||||||
|
|
||||||
|
### The common thread
|
||||||
|
|
||||||
|
In all six games, the setup screen generates ASYMMETRIC STARTING CONDITIONS.
|
||||||
|
The same world, entered from different positions, produces different
|
||||||
|
information access, different social proximity, and different verb
|
||||||
|
availability. The asymmetry IS the replayability. Our character creation
|
||||||
|
should produce a starting POSITION in an information landscape — not a
|
||||||
|
stat block, not a story, not a difficulty setting.
|
||||||
|
|
||||||
|
## Participants and Roles
|
||||||
|
|
||||||
|
| Agent | Role | Why they're here |
|
||||||
|
|-------|------|-----------------|
|
||||||
|
| Nigel | Replayability lead | Structural randomness, seed design, "what happens on playthrough 10?" |
|
||||||
|
| Paula | Narrative lead | Starting NPC relationships, character-specific story hooks, social web implications |
|
||||||
|
| Gestalt | Systems lead | How character choice propagates through mechanics (KG, dialogue tiers, movement, perception) |
|
||||||
|
| Miri | Worldbuilding | Which archetypes fit the Krenn System, canon constraints, lore accuracy |
|
||||||
|
| Tyre | Architecture | Implementation cost reality check. "That's 4 new ECS components — is it worth it?" |
|
||||||
|
| Qatux | Documenter | Track decisions, cross-references, dissent. Maintain running reference list. |
|
||||||
|
|
||||||
|
## Round Structure
|
||||||
|
|
||||||
|
### Round 1 — The Lens Question (Nigel leads)
|
||||||
|
|
||||||
|
Single question: what does character selection actually select? (Q1)
|
||||||
|
|
||||||
|
Nigel opens with a replayability scoring of each model (A/B/B2/C) — 3
|
||||||
|
bullet points per model on the replayability axis. This is a BASELINE, not
|
||||||
|
a verdict. Participants then argue from their domain against that baseline.
|
||||||
|
This converges faster than open advocacy.
|
||||||
|
|
||||||
|
Target: agree on the model by end of round.
|
||||||
|
|
||||||
|
### Round 2 — The Seed Boundary (Gestalt leads)
|
||||||
|
|
||||||
|
Given the model from Round 1: draw the exact line between world seed,
|
||||||
|
character choice, and player customization. Produce the three-column
|
||||||
|
table (Q2). Each participant fills in their domain's rows.
|
||||||
|
|
||||||
|
### Round 3a — Gate Activation (Paula leads)
|
||||||
|
|
||||||
|
How does contamination trigger work per character? (Q3)
|
||||||
|
This flows directly from Round 1's model decision. Paula leads because
|
||||||
|
gate activation is fundamentally a narrative question — when does the
|
||||||
|
story shift?
|
||||||
|
|
||||||
|
### Round 3b — Quest Seeding (Nigel leads, Tyre has implementation floor)
|
||||||
|
|
||||||
|
How do quest templates interact with character choice? (Q4)
|
||||||
|
Nigel leads because quest variation is the replayability engine. Tyre gets
|
||||||
|
explicit authority to reject quest template proposals that require new
|
||||||
|
architecture — every template decision has an implementation cost that
|
||||||
|
spirals without active checking.
|
||||||
|
|
||||||
|
### Round 4 — Conditions, Toggles & Synthesis (all)
|
||||||
|
|
||||||
|
Game conditions and what's toggleable (Q5). Then run the concrete
|
||||||
|
playthrough 2 test (Q6). Nigel presents the scenario. Each participant
|
||||||
|
must answer the four concrete questions. If they can't, the design has
|
||||||
|
a gap — find it and fix it before closing.
|
||||||
|
|
||||||
|
## Required Reading for Participants
|
||||||
|
|
||||||
|
- `decisions/scope.md` — D-005, D-013, D-027, D-029, D-053
|
||||||
|
- `decisions/content.md` — D-023, D-028, D-032, D-034
|
||||||
|
- `decisions/architecture.md` — D-041 (knowledge graph)
|
||||||
|
- `decisions/questions.md` — Q-010, Q-011
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
- **D-record:** Character creation model (resolves Q-011)
|
||||||
|
- **D-record:** Seed boundary table (what varies by seed vs character vs player)
|
||||||
|
- **D-record:** Gate activation trigger design
|
||||||
|
- **D-record or Q:** Quest seeding model (may produce a Q if full design deferred)
|
||||||
|
- **D-record:** Game condition toggles (what's configurable, what isn't)
|
||||||
|
- Ticket updates for Sprint 19+ backlog as needed
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Workshop Outcomes: v0.1 Content Gap Analysis
|
||||||
|
|
||||||
|
**Workshop:** v0.1 Content Gap Analysis
|
||||||
|
**Date:** 2026-02-11
|
||||||
|
**Rounds:** 2 (Analysis + Synthesis)
|
||||||
|
**Participants:** Mellanie, Paula, Araminta, Miri, Gestalt, Ozzie
|
||||||
|
**Facilitator:** Jeroen
|
||||||
|
**Documenter:** Qatux
|
||||||
|
**Status:** DONE — all decisions actioned, tickets created
|
||||||
|
**Full notes:** `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Workshop Accomplished
|
||||||
|
|
||||||
|
Six agents independently analyzed 9 content layers across the vertical slice (D-027), then synthesized across all outputs. The project lead issued 9 directive decisions between rounds. Remarkable cross-agent convergence: the Dual Lens Guide, monologue as primary carrier, and the tag taxonomy were independently identified by multiple agents without coordination. THE FRIEND concept evolved from Ozzie's emotional instinct to Paula's structural design to Mellanie's authoring plan in a single workshop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Produced
|
||||||
|
|
||||||
|
| ID | Decision | Domain | Source |
|
||||||
|
|----|----------|--------|--------|
|
||||||
|
| D-032 | Separate monologue pools per character | content.md | Lead directive #1 |
|
||||||
|
| D-033 | Entity color = relationship to player | perception.md | Araminta R1 + lead directive #2 |
|
||||||
|
| D-034 | THE FRIEND production-level NPC pattern | content.md | Ozzie concept + lead directive #4 + Paula design |
|
||||||
|
| D-035 | Converged tag taxonomy for line pools (6+3 tags) | content.md | Gestalt + Mellanie convergence |
|
||||||
|
| D-036 | Sova Transit District / Krenn System as v0.1 setting | content.md | Miri R1 + lead directive #6 |
|
||||||
|
| D-037 | Contraband specification | content.md | Miri R1/R2 |
|
||||||
|
| D-038 | Audio in v0.1 scope via Stable Audio Open (8 files) | scope.md | Lead directives #3 + #9 |
|
||||||
|
| D-039 | v0.1 wow moment scope — all 6 moments | scope.md | Ozzie R1/R2 + lead directive #8 |
|
||||||
|
| D-040 | Wiki taxonomy structure | process.md | Miri R2 + lead directive #5 |
|
||||||
|
|
||||||
|
All 9 decisions confirmed by project lead between rounds as non-negotiable directives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions Identified
|
||||||
|
|
||||||
|
| ID | Question | Owner | Status |
|
||||||
|
|----|----------|-------|--------|
|
||||||
|
| Q-012 | How does the generation expansion pass work? LLM, template-based, or rule-based? | Gestalt, Mellanie | Raised this workshop |
|
||||||
|
| Q-013 | How does the line previewer handle THE FRIEND's temporal progression? | Gestalt, Dudley | Raised this workshop |
|
||||||
|
| Q-014 | Audio timing with monologue — when does the chime fire relative to text? | Gestalt, Ozzie | Raised this workshop |
|
||||||
|
| Q-015 | Does 4x generation expansion apply to THE FRIEND's custom lines? | Mellanie, Gestalt | Raised this workshop |
|
||||||
|
| Q-016 | Knowledge hierarchy for monologue prerequisites | Gestalt, Paula | Raised this workshop |
|
||||||
|
| Q-017 | Triangle pressure threshold — what events trigger escalation? | Gestalt, Paula | Raised this workshop |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets Created
|
||||||
|
|
||||||
|
48 total tickets (42 new + 6 updates). See `docs/workshops/content-gap-analysis_v0_1/TICKETS.md` for full list.
|
||||||
|
|
||||||
|
**Critical (9 new):** #297 Kael Davan full profile, #298 Sera Venn full profile, #299 Opening hook (smuggler), #300 Opening hook (detective), #301 Wiki taxonomy, #302 Sova Texture Appendix, #303 v0.1 Visual Grammar, #304 Entity Color System Spec, #305 Dialogue selection pipeline.
|
||||||
|
|
||||||
|
**High (23 new):** Content and narrative (#306, #307, #310, #328), visual and spatial layouts (#311-318), worldbuilding (#319-322), systems and implementation (#308, #309, #323-327).
|
||||||
|
|
||||||
|
**Medium (10 new):** Mirror moments, tutorial content, environmental standards, tell derivation (#329-338).
|
||||||
|
|
||||||
|
**Updated (6):** #261 promoted to critical with expanded scope; #189, #168, #124, #90, #193 updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Flags
|
||||||
|
|
||||||
|
- Detective's FRIEND confirmed as Sera Venn (Paula, not Mellanie's Lera proposal). Lera Sessik remains the bar owner.
|
||||||
|
- NPC triangle count: Paula reconciled from 7 to 5 triangles in Round 2. Five-triangle model is canonical.
|
||||||
|
- Tag taxonomy convergence was independent: Mellanie and Gestalt proposed nearly identical structures without coordination.
|
||||||
|
- Dual Lens Guide (#261) is the single highest-risk dependency — everything downstream blocks on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Path Produced
|
||||||
|
|
||||||
|
Dual Lens Guide (#261) → Voice Kits → THE FRIEND Content Packs → Validation → Content at Scale.
|
||||||
|
|
||||||
|
Three parallel tracks: narrative (Paula), setting (Miri), systems (Gestalt + Araminta) — converging at content pack production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled by Qatux. Source: `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md`, `TICKETS.md`. Decisions in `decisions/content.md` (D-032 through D-040 excl. D-033 in perception.md, D-038/D-039 in scope.md, D-040 in process.md).*
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Workshop Outcomes: Knowledge Graph & Information Boundaries
|
||||||
|
|
||||||
|
**Workshop:** Knowledge Graph & Information Boundaries
|
||||||
|
**Date:** 2026-02-11
|
||||||
|
**Rounds:** 2 (Design + Synthesis)
|
||||||
|
**Participants:** Tyre, Gestalt, Paula, Dudley, Si
|
||||||
|
**Facilitator:** Jeroen
|
||||||
|
**Documenter:** Qatux
|
||||||
|
**Status:** DONE — fully actioned, decisions filed, tickets created
|
||||||
|
**Full notes:** `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Workshop Accomplished
|
||||||
|
|
||||||
|
Replaced the `InformationInventory { known_facts: Vec<String> }` placeholder with a fully specified knowledge graph data model. Five agents analyzing from different angles converged independently on all fundamentals: per-entity ECS component, stable entity IDs, per-entry provenance tracking. The synthesis resolved the only substantive debate (centralized resource vs. per-entity component) unanimously in favour of per-entity. The resulting D-041 spec is the foundation for asymmetric information as a playable mechanic — and became load-bearing for D-010, D-011, D-017, D-028, D-033, and Q-016.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Major Decision Produced
|
||||||
|
|
||||||
|
### D-041: Knowledge Graph Data Model
|
||||||
|
|
||||||
|
The core design decision of this workshop. Full specification in `decisions/perception.md`.
|
||||||
|
|
||||||
|
Key architectural choices:
|
||||||
|
- `KnowledgeGraph` as a Bevy ECS `Component` on each entity (not a centralized resource)
|
||||||
|
- `StableEntityId` (`u64`-based) for cross-reference stability; runtime `EntityRegistry` for bidirectional mapping
|
||||||
|
- `BTreeMap<StableId, EntityKnowledge>` + `BTreeMap<FactId, FactKnowledge>` per entity
|
||||||
|
- Four confidence levels: `Direct` > `KnowsDetails` > `KnowsOf` > `Suspects`
|
||||||
|
- Three knowledge states: `Active`, `Stale`, `Contradicted`
|
||||||
|
- `KnowledgeSource` tracked per-entry (not per-graph): `DirectObservation`, `ToldBy`, `Background`, `Heard`
|
||||||
|
- Event-driven updates via `KnowledgeEventQueue`; decay pass once per game-minute
|
||||||
|
- Sprint 2 scope: data structures + direct observation + basic decay only
|
||||||
|
|
||||||
|
### Additional Decisions Implied (formalized later)
|
||||||
|
|
||||||
|
The workshop produced the architectural foundation that fed into:
|
||||||
|
- D-012: Information boundaries (per-character knowledge isolation)
|
||||||
|
- Formal resolution of Q-016: Knowledge hierarchy (`Suspects` < `KnowsOf` < `KnowsDetails` < `Direct`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions Identified
|
||||||
|
|
||||||
|
| ID | Question | Sprint Impact | Resolved By |
|
||||||
|
|----|----------|---------------|-------------|
|
||||||
|
| Q-024 | Gossip propagation timing (immediate vs queued) | Sprint 3+ | D-080 (knowledge-flow-npc-boundaries workshop) |
|
||||||
|
| Q-025 | Knowledge graph cap and eviction policy | Sprint 3+ | D-080 (closed: no cap needed at projected v0.1 scale) |
|
||||||
|
| Q-026 | Contradiction detection algorithm | Sprint 3+ (THE FRIEND arc) | D-083 (knowledge-flow-npc-boundaries workshop) |
|
||||||
|
|
||||||
|
None blocked Sprint 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets Created
|
||||||
|
|
||||||
|
8 new tickets added to Sprint 2, all under epic #351. Sprint 2 expanded from 14 to 22 tickets (+6.5 developer-days).
|
||||||
|
|
||||||
|
| # | Title | Priority | Estimate |
|
||||||
|
|---|-------|----------|----------|
|
||||||
|
| #361 | KnowledgeGraph component + types (D-041) | critical | 1 day |
|
||||||
|
| #362 | StableEntityId + EntityRegistry resource | critical | 1 day |
|
||||||
|
| #363 | KnowledgeEventQueue + processing system | high | 0.5 day |
|
||||||
|
| #364 | Direct observation knowledge flow | critical | 0.5 day |
|
||||||
|
| #365 | Basic knowledge decay system | high | 0.5 day |
|
||||||
|
| #366 | Observer snapshot knowledge integration | critical | 1 day |
|
||||||
|
| #367 | Knowledge graph unit test suite | high | 1 day |
|
||||||
|
| #368 | Knowledge vocabulary for v0.1 content | high | 0.5 day |
|
||||||
|
|
||||||
|
**Existing tickets affected:**
|
||||||
|
- #89 (Information inventory) — cancelled, subsumed by #361
|
||||||
|
- #269 (CauseChain component) — marked done (already implemented)
|
||||||
|
- #138-142 (Information boundary epics) — reparented under #351; #139, #141, #142 deferred to Sprint 3; #140 cancelled (merged into #366)
|
||||||
|
|
||||||
|
**Critical path impact:** #361, #362, #363, #364 added serially to Sprint 2 critical path (10 tickets serial, up from 6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 2 Completion Criteria (Added by Workshop)
|
||||||
|
|
||||||
|
Two new acceptance criteria added to Sprint 2's definition of done:
|
||||||
|
- Entity color reflects relationship state from knowledge graph (#361, #366)
|
||||||
|
- Remembered (not visible) entities appear as ghosts at last-known position (#361, #366)
|
||||||
|
|
||||||
|
**Knowledge graph proof:** Observe an NPC, walk away, return. NPC appears as ghost at last-known position while not in LOS. Color shifts by relationship state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled by Qatux. Source: `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`. Primary decision in `decisions/perception.md` (D-041).*
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Workshop Outcomes: v0.1 Content Scoping
|
||||||
|
|
||||||
|
**Workshop:** v0.1 Content Scoping
|
||||||
|
**Date:** 2026-02-12
|
||||||
|
**Rounds:** 2 + closing round (lead resolutions)
|
||||||
|
**Participants:** Gestalt, Paula, Tyre, Mellanie, Stig, Dudley, Si, Qatux
|
||||||
|
**Facilitator:** Jeroen
|
||||||
|
**Documenter:** Qatux
|
||||||
|
**Status:** CLOSED — all recoverable decisions filed, tickets created
|
||||||
|
**Full notes:** `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Workshop Accomplished
|
||||||
|
|
||||||
|
Applied the Wiki Review's long-term generator strategy to the immediate v0.1 hand-authored proof. Produced 20 decisions (D-042 through D-061), 38 new tickets, canonical NPC mapping for 17 characters, the 16-key EntityKnowledge specification, full content directory architecture, and a Sprint 3-5 roadmap. Tyre and Dudley independently produced structurally identical ObserverSnapshot v3 definitions without coordination — confirmed the architecture was sound. Lead issued 4 decisions resolving the major Round 1 disagreements between rounds, then resolved 3 remaining questions in a closing round.
|
||||||
|
|
||||||
|
**Note on decision IDs:** Several IDs assigned at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-087 (content directory structure), D-089 (NPC canonical mapping method), D-091 (EntityKnowledge 16-key canonical set), Q-031 through Q-034.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Produced
|
||||||
|
|
||||||
|
### From Round 1 Consensus (7)
|
||||||
|
|
||||||
|
| ID | Decision | Domain |
|
||||||
|
|----|----------|--------|
|
||||||
|
| D-042 | Drin promoted from Tier 3 to Tier 2 | content.md |
|
||||||
|
| D-043 | THE NOBODY mechanic deferred to v0.2; hidden data ships in v0.1 content | scope.md |
|
||||||
|
| D-044 | v0.1 interaction model: 7 verbs (Move, Look, Monologue, Examine Object, Examine NPC, Talk, Overhear) | scope.md |
|
||||||
|
| D-045 | v0.1 scope IN: news ticker, PC-as-NPC, time progression, relationship state transitions | scope.md |
|
||||||
|
| D-046 | v0.1 scope OUT: inventory, stealth, combat, save/load, lattice modification | scope.md |
|
||||||
|
| D-047 | v0.1 triangles: 3 active forks (T1, T2, T4), 2 passive tensions (T3, T5) | content.md |
|
||||||
|
| D-048 | Client receives all text from server via state updates; client does not load content files | architecture.md |
|
||||||
|
|
||||||
|
### From Round 2 + Closing (13)
|
||||||
|
|
||||||
|
| ID | Decision | Domain |
|
||||||
|
|----|----------|--------|
|
||||||
|
| D-049 | YAML is the content file format for v0.1; RON is optional build-time optimization | architecture.md |
|
||||||
|
| D-050 | Gestalt's NPC pattern/motivation mapping canonical for v0.1; Paula's emotional layer becomes v0.2 annotations | content.md |
|
||||||
|
| D-051 | v0.1 ships single context-sensitive verb; multi-verb architecture modeled underneath | architecture.md |
|
||||||
|
| D-052 | 3-state pause: Normal (100%), Overlay (50%), Paused (0%); server-authoritative | architecture.md |
|
||||||
|
| D-053 | Self-contained triangle forks for v0.1; no cross-triangle cascade (v0.2) | content.md |
|
||||||
|
| D-054 | ObserverSnapshot v3: adds sim_speed, nearby_interactions, active_dialogue, monologue, overheard, knowledge_updates, examine_result, ticker_headlines | architecture.md |
|
||||||
|
| D-055 | 16 EntityKnowledge keys; 4 new role-perspective keys; trust_read merged into trust_level; secret_held → leverage_held | architecture.md |
|
||||||
|
| D-056 | PC voice registers: smuggler (feeling-first, fragments, physical); detective (analysis-first, complete sentences, institutional) | content.md |
|
||||||
|
| D-057 | Content directory: content/ with _schema/, global/, districts/ top-level split; JSON Schema validation at build time | process.md |
|
||||||
|
| D-058 | THE FRIEND content pack template: Kael Davan, 91 lines across 5 arc phases | content.md |
|
||||||
|
| D-059 | Monologue display: 160 char max, 2-line max, 4-6s display, 2s cooldown, queue depth 1, 9-level priority | architecture.md |
|
||||||
|
| D-060 | actions[] renamed to verbs[] across all surfaces | architecture.md |
|
||||||
|
| D-061 | No ticket merges across domain teams | process.md |
|
||||||
|
|
||||||
|
### Retrospective Filings (ID collisions resolved)
|
||||||
|
|
||||||
|
| ID | Decision | Domain |
|
||||||
|
|----|----------|--------|
|
||||||
|
| D-087 | Content directory structure (content/ split) | process.md |
|
||||||
|
| D-089 | Canonical NPC pattern/motivation mapping method | content.md |
|
||||||
|
| D-091 | EntityKnowledge 16-key canonical specification | architecture.md |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions Carried Forward
|
||||||
|
|
||||||
|
| ID | Question | Status |
|
||||||
|
|----|----------|--------|
|
||||||
|
| Q-031 | NPC surnames for Drin, Sess, Tav awaiting Miri validation | Informational |
|
||||||
|
| Q-032 | Interaction struct naming: AvailableActions (Tyre) vs EntityInteractions (Dudley) | Resolved at implementation |
|
||||||
|
| Q-033 | 695 authored items: validated as scope input but not independently verified | Informational |
|
||||||
|
| Q-034 | Dialogue max-width: pixel value for 20% height / max-width constraint | Pending lead call |
|
||||||
|
|
||||||
|
None blocked Sprint 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets Created
|
||||||
|
|
||||||
|
38 new tickets + 10 existing ticket updates. See `si-ticket-changes.md` for full list.
|
||||||
|
|
||||||
|
**Teams:** copy (21), server (13), client (2), ci (1).
|
||||||
|
|
||||||
|
**Critical path:** #261 (Dual Lens Authoring Guide) is the single biggest blocker — directly blocks 9 downstream tickets across the content pipeline. Five-day time-box recommended.
|
||||||
|
|
||||||
|
| Series | Count | Domain |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| A (Wiki content fixes) | 8 | copy |
|
||||||
|
| B (Style guides + specs) | 5 | copy |
|
||||||
|
| C (Content directory + schemas) | 10 | copy/server/ci |
|
||||||
|
| D (Design specs) | 2 | server/copy |
|
||||||
|
| NEW 1-7 (Workshop rounds) | 7 | copy |
|
||||||
|
| NEW 8-14 (Lead decisions, excl. killed NEW-12) | 6 | server/client |
|
||||||
|
|
||||||
|
**Killed:** NEW-12 (client pause state machine — pause is server-authoritative, client sends IPC command only).
|
||||||
|
|
||||||
|
**Sprint allocation:** Sprint 3 — foundations and specs. Sprint 4 — content conversion and authoring begins. Sprint 5+ — content at scale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NPC Canonical Mapping (17 NPCs)
|
||||||
|
|
||||||
|
The Gestalt-Paula synthesis produced the v0.1 canonical pattern/motivation mapping for all 17 Sova NPCs. This is the authoritative reference for content authoring.
|
||||||
|
|
||||||
|
| Name | Tier | Pattern | Motivation |
|
||||||
|
|------|------|---------|-----------|
|
||||||
|
| Kael Davan | T1 | FRIEND | OPERATOR |
|
||||||
|
| Sera Venn | T1 | FRIEND | WITNESS |
|
||||||
|
| Naia Tamm | T1* | MIRROR | CIVILIAN |
|
||||||
|
| Voss, Lera, Torek, Devra, Maret, Resha, Drin, Renn, Pell, Harek | T2 | (varied) | (varied) |
|
||||||
|
| Sess, Olin, Sabel, Tav | T3 | (varied) | (varied) |
|
||||||
|
|
||||||
|
Off-stage: Nils Davan — GHOST + HANDLER.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled by Qatux. Source: `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`. Decisions in relevant `decisions/` domain files (D-042 through D-061, D-087, D-089, D-091). Open questions in `decisions/questions.md` (Q-031 through Q-034).*
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Workshop Outcomes: v0.1 Gap Analysis
|
||||||
|
|
||||||
|
**Workshop:** v0.1 Gap Analysis
|
||||||
|
**Date:** 2026-02-11
|
||||||
|
**Rounds:** 2 (Gap identification + Synthesis)
|
||||||
|
**Participants:** Gestalt, Tyre, Ozzie, Paula, Nigel, Gore, Hoshe
|
||||||
|
**Facilitator:** Jeroen
|
||||||
|
**Documenter:** Qatux
|
||||||
|
**Status:** DONE — fully actioned, decisions confirmed, tickets created
|
||||||
|
**Full notes:** `docs/workshops/v01-gap-analysis/si-ticket-changes.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Workshop Accomplished
|
||||||
|
|
||||||
|
Stress-tested the full v0.1 plan (232 tickets, 7 initiatives) against three tracks: strength of concept proof, fun and wow factor, and completeness. Found significant gaps: missing infrastructure (collision, pathfinding, time system), a missing experience layer (observation pipeline, interaction verbs), and systemically underpriced tickets across social, dialogue, and replayability systems. The workshop ended with 273 tickets (+41) and a clear Sprint 1-5 dependency chain.
|
||||||
|
|
||||||
|
Key outcome: the workshop revealed that tile collision, A* pathfinding, the observation event generator, and the interaction dispatcher were absent from the ticket catalog despite being architectural blockers for almost everything else. These were added at critical priority.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Confirmed
|
||||||
|
|
||||||
|
The workshop confirmed 6 decisions. These fed directly into later formal D-records:
|
||||||
|
|
||||||
|
| Decision | Formal Record | Notes |
|
||||||
|
|----------|---------------|-------|
|
||||||
|
| Deterministic replay is an architectural requirement, not just test infrastructure | D-010 (reinforced) | Promoted #201 to critical |
|
||||||
|
| Time system: 10 tps, 4 day phases, diegetic clock | D-031 | Resolved Q-009; ticket #25 renamed and promoted |
|
||||||
|
| Godot test framework = gdUnit4 | process.md | Tyre reversed GUT recommendation after Hoshe's analysis |
|
||||||
|
| IPC testing = three-layer architecture (fixture, protocol mock, real integration) | architecture.md | Hoshe + Tyre |
|
||||||
|
| CauseChain is a production component, not test pollution | architecture.md | #269 created |
|
||||||
|
| Dual Lens Authoring Guide must precede content authoring | D-038 extended | All agents converged |
|
||||||
|
|
||||||
|
These decisions fed into the scope and architecture domain files. The three-tier content system (D-023), vertical slice (D-027), and population ratios (D-029) were validated against the gap analysis findings and confirmed as sound.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket Changes
|
||||||
|
|
||||||
|
**Before:** 232 tickets | **After:** 273 tickets (+41)
|
||||||
|
|
||||||
|
| Change Type | Count |
|
||||||
|
|-------------|-------|
|
||||||
|
| Priority promotions (existing) | 15 |
|
||||||
|
| New epics | 3 |
|
||||||
|
| New stories | 38 |
|
||||||
|
| Dependency records added | 21 |
|
||||||
|
| Renamed + promoted | 1 (#25) |
|
||||||
|
|
||||||
|
### Priority Promotions (15 tickets)
|
||||||
|
|
||||||
|
**Medium → High (11):** #103 (relationship dynamics), #105 (tolerance threshold triggers), #171 (trust-gated gossip), #172 (unprompted disclosure), #173 (trait modifier system), #175 (entanglement ratio), #176 (NPC pool generation), #121 (character voice variation), #126 (medium-range visual indicators), #162 (storyteller module activation), #178 (seed-based variation).
|
||||||
|
|
||||||
|
**High → Critical (3):** #201 (deterministic replay), #182 (divergent starting knowledge), #183 (divergent relationships).
|
||||||
|
|
||||||
|
### New Epics (3)
|
||||||
|
|
||||||
|
| ID | Title | Priority |
|
||||||
|
|----|-------|----------|
|
||||||
|
| #233 | Movement & Collision | critical |
|
||||||
|
| #234 | Observation & Interaction | critical |
|
||||||
|
| #235 | Game State Management | high |
|
||||||
|
|
||||||
|
### Critical New Stories
|
||||||
|
|
||||||
|
| ID | Title | Priority |
|
||||||
|
|----|-------|----------|
|
||||||
|
| #236 | Tile collision system | critical |
|
||||||
|
| #239 | Observation event generator | critical |
|
||||||
|
| #240 | Player interaction system and dispatcher | critical |
|
||||||
|
| #237 | Tile-based A* pathfinding | high |
|
||||||
|
| #238 | NPC path following and movement | high |
|
||||||
|
| #253 | Monologue content architecture | high |
|
||||||
|
| #261 | Dual Lens Authoring Guide | high |
|
||||||
|
| (+ 31 more stories) | | high/medium/low |
|
||||||
|
|
||||||
|
### Critical Path Produced
|
||||||
|
|
||||||
|
```
|
||||||
|
Sprint 1: #236 (collision) → #237 (pathfinding) → #238 (NPC movement)
|
||||||
|
#25 (game clock) → #88 (daily routines)
|
||||||
|
Sprint 2: #239 (observation generator) + #240 (interaction dispatcher)
|
||||||
|
Sprint 3: Observation verbs, NPC conversation, monologue, triangle escalation
|
||||||
|
Sprint 4: Content pipeline (#261 dual lens guide blocks all content packs)
|
||||||
|
Sprint 5: Validation and playtest protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None formally raised as Q-records by this workshop — the gap analysis was primarily a ticket and priority exercise. Questions about content authoring (Q-012 through Q-017) were raised in the companion content-gap-analysis workshop the same day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled by Qatux. Source: `docs/workshops/v01-gap-analysis/si-ticket-changes.md`. Decisions confirmed fed into `decisions/scope.md` (D-023, D-027, D-029) and `decisions/architecture.md` (D-010, D-031).*
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Workshop Outcomes: Wiki Review & Content Standards
|
||||||
|
|
||||||
|
**Workshop:** Wiki Review & Content Standards
|
||||||
|
**Date:** 2026-02-12
|
||||||
|
**Rounds:** 4 + lead interview between R3 and R4
|
||||||
|
**Participants:** Paula, Mellanie, Miri, Gestalt, Gore, Nigel, Ozzie, Tyre, Qatux, Si
|
||||||
|
**Facilitator:** Jeroen
|
||||||
|
**Documenter:** Qatux
|
||||||
|
**Status:** CLOSED — all recoverable decisions filed, tickets created
|
||||||
|
**Full notes:** `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Workshop Accomplished
|
||||||
|
|
||||||
|
Began as a v0.1 wiki review (45 files, ticket #368) and was redirected between rounds 2 and 3 by a major strategic reframe: the lead declared the target as 300 populated worlds before DLC. This transformed the workshop from a content authoring strategy into a generator specification strategy. All Round 4 responses independently arrived at the same conclusion: the workshop had been designing generator specifications all along. The district skeleton, NPC composition matrix, Sacred/Profane framework, and pool architecture were already generator-shaped.
|
||||||
|
|
||||||
|
**The strategic reframe:** Old model: writers produce districts, tooling accelerates writers. New model: engineers produce generators, writers produce generator inputs, tooling IS the product.
|
||||||
|
|
||||||
|
**Note on decision IDs:** Several IDs proposed at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-088 (300-world generator model), D-090 (three-tier world authoring), D-092 (Sacred/Profane/Middle Kingdom framework), Q-030, Q-035 through Q-039.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Produced
|
||||||
|
|
||||||
|
### Long-Term Strategy Decisions (Confirmed by Lead)
|
||||||
|
|
||||||
|
| ID | Decision | Domain |
|
||||||
|
|----|----------|--------|
|
||||||
|
| D-088 | 300 worlds before DLC — generator model required | scope.md |
|
||||||
|
| D-090 | Three-tier world authoring: Landmark (10-15 hand-authored), Regional (30-50 template), Generated (230-260 procedural) | scope.md |
|
||||||
|
| D-092 | Sacred/Profane/Middle Kingdom randomization framework | architecture.md |
|
||||||
|
|
||||||
|
### Supporting Decisions (Rounds 1-2, v0.1 Specific)
|
||||||
|
|
||||||
|
| ID | Decision | Domain |
|
||||||
|
|----|----------|--------|
|
||||||
|
| Q-030 | Cultural ingredients menu: 6 ingredient categories, Heritage OPTIONAL, derivation function produces cultural parameters | pending formalization |
|
||||||
|
| — | Hael renamed to Naia Tamm (resolves Kael/Hael sonic collision) | content.md |
|
||||||
|
| — | Three-system NPC architecture: Thematic Patterns + Functional Motivations + Composition rules | content.md |
|
||||||
|
| — | Cultural brief = generation seed (seed.yaml + brief.md dual artifact) | process.md |
|
||||||
|
| — | 8 PC archetypes at v1.0; archetypes are FLUID positions, not classes | scope.md |
|
||||||
|
| — | THE NOBODY: dynamic tier promotion (NOBODY → NOTICED → RECOGNIZED → KNOWN → INVESTED) | scope.md |
|
||||||
|
|
||||||
|
The specific v0.1 decisions (Naia rename, NPC architecture, THE NOBODY, THE MIRROR) were carried forward and filed in the subsequent v0.1 Content Scoping workshop where IDs were formally assigned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions Raised
|
||||||
|
|
||||||
|
| ID | Question | Status |
|
||||||
|
|----|----------|--------|
|
||||||
|
| Q-030 | Cultural ingredients menu: full formalization of 6 categories, null-heritage behavior, derivation function specification | Pending |
|
||||||
|
| Q-035 | Naming algorithm: phonetic rules vs word lists — which approach for 300-world cultural naming | Pending |
|
||||||
|
| Q-036 | Gate topology design: connectivity requirements, hub placement, small-world properties | Pending |
|
||||||
|
| Q-037 | Storyteller cultural literacy: how storyteller adapts pacing to cultural trust-building rates | Pending |
|
||||||
|
| Q-038 | NPC pattern composition rules: forbidden/preferred combination formalization | Pending |
|
||||||
|
| Q-039 | Modding toolkit: content pack manifest, ADD/REPLACE/MERGE overlay operations | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets Created
|
||||||
|
|
||||||
|
29 new tickets (1 epic, 9 stories, 19 tasks) + 4 existing ticket updates. See `si-ticket-changes.md` for full list.
|
||||||
|
|
||||||
|
**Teams:** copy (21), server (7), ci (1).
|
||||||
|
|
||||||
|
### Existing Ticket Updates (4)
|
||||||
|
|
||||||
|
| ID | Action |
|
||||||
|
|----|--------|
|
||||||
|
| #301 | Update description with concrete taxonomy rules from workshop |
|
||||||
|
| #319 | Update — Miri's Krenn brief supersedes original scope |
|
||||||
|
| #368 | Mark done — wiki at wiki/ is the delivered output |
|
||||||
|
| #261 | No change — confirmed still blocking, assign to copy when ready |
|
||||||
|
|
||||||
|
### New Epic
|
||||||
|
|
||||||
|
**Wiki Review Workshop Outputs** — parent epic for all workshop-produced tickets. Priority: high. Team: copy.
|
||||||
|
|
||||||
|
### Selected New Tickets
|
||||||
|
|
||||||
|
| Series | Count | Examples |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| A (Wiki content updates) | 12 | A1 canonical names, A2 Hael→Naia rename, A3 Krenn brief, A7 smuggler attributes, A11 Triangle 1 fix |
|
||||||
|
| B (Style guides + specs) | 5 | B1 NPC Authoring Style Guide, B2 MIRROR spec, B3 PC-as-NPC spec, B4 Smuggler voice card |
|
||||||
|
| C (Content directory + schemas) | 10 | C1 design doc, C2 directory skeleton, C3 schemas, C9 validate-content CLI |
|
||||||
|
| D (Design specs) | 4 | D1 cultural_gate design, D2 seed config schema, D3 secondary contraband, D4 news ticker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred to Future Workshops
|
||||||
|
|
||||||
|
The wiki-review triggered scoping of several follow-on workshops:
|
||||||
|
|
||||||
|
- **Control & Interaction Scheme** — brief written during this workshop; held separately (`docs/workshops/control-interaction/`)
|
||||||
|
- Perception system gravity variation for world transitions
|
||||||
|
- Full NPC pattern composition rules (forbidden/preferred lists)
|
||||||
|
- Gate topology design
|
||||||
|
- Storyteller cultural literacy
|
||||||
|
- LLM-assisted content pipeline design
|
||||||
|
- Modding toolkit specification
|
||||||
|
- Inter-world political generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Quotes Preserved
|
||||||
|
|
||||||
|
- Nigel: "Two players, same world, same seed, different experience because they noticed different people."
|
||||||
|
- Gore: "Learning to see is the endgame."
|
||||||
|
- Gestalt: "Hotline Miami movement + Disco Elysium interaction."
|
||||||
|
- Gore: "The theme is the field, not any specific phrasing of it. The field is: what does it cost to be human inside something bigger than yourself?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled by Qatux. Source: `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`. Decisions in `decisions/scope.md` (D-088, D-090) and `decisions/architecture.md` (D-092). Open questions in `decisions/questions.md` (Q-030, Q-035 through Q-039).*
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: The Settled Reach
|
name: The Settled Reach
|
||||||
version: 0.1.15
|
version: 0.1.19
|
||||||
repository: settled-reach
|
repository: settled-reach
|
||||||
codename: commonwealth
|
codename: commonwealth
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "settled-reach-server"
|
name = "settled-reach-server"
|
||||||
version = "0.1.15"
|
version = "0.1.19"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -24,3 +24,13 @@ gauntlet = []
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Explicit test target for the Layer 3 integration module (D-030, ticket #200).
|
||||||
|
# tests/integration/mod.rs cannot be auto-discovered by cargo (only top-level
|
||||||
|
# *.rs files in tests/ are auto-discovered). This declaration makes it a named
|
||||||
|
# test binary: `cargo test --test integration_layer3`.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[[test]]
|
||||||
|
name = "integration_layer3"
|
||||||
|
path = "tests/integration/mod.rs"
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ impl LocalBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SimBridge for LocalBridge {
|
impl SimBridge for LocalBridge {
|
||||||
|
fn send_handshake(&self) -> Result<(), BridgeError> {
|
||||||
|
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
|
||||||
|
let msg = HandshakeMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
let payload = rmp_serde::to_vec_named(&msg)?;
|
||||||
|
let mut writer = self
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||||
|
write_framed(writer.get_mut(), &payload)?;
|
||||||
|
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
let payload = rmp_serde::to_vec_named(snapshot)?;
|
let payload = rmp_serde::to_vec_named(snapshot)?;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ pub enum BridgeError {
|
|||||||
/// Abstracts transport layer (D-020)
|
/// Abstracts transport layer (D-020)
|
||||||
/// Implemented by LocalBridge (stdio) and future NetworkBridge
|
/// Implemented by LocalBridge (stdio) and future NetworkBridge
|
||||||
pub trait SimBridge: Send + Sync {
|
pub trait SimBridge: Send + Sync {
|
||||||
|
/// Send the protocol handshake as the first framed message (#555).
|
||||||
|
/// Must be called exactly once, immediately after connection, before
|
||||||
|
/// any ObserverSnapshot is sent.
|
||||||
|
fn send_handshake(&self) -> Result<(), BridgeError>;
|
||||||
|
|
||||||
/// Send an observer snapshot to the client
|
/// Send an observer snapshot to the client
|
||||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
|
||||||
|
|
||||||
@@ -55,6 +60,10 @@ impl BridgeResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn send_handshake(&self) -> Result<(), BridgeError> {
|
||||||
|
self.inner.send_handshake()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
self.inner.send_snapshot(snapshot)
|
self.inner.send_snapshot(snapshot)
|
||||||
}
|
}
|
||||||
@@ -64,15 +73,40 @@ impl BridgeResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tracks whether the protocol handshake has been sent (#555).
|
||||||
|
/// Inserted by BridgePlugin as Pending. Set to Complete in main.rs after
|
||||||
|
/// `send_handshake()` succeeds. `receive_bridge_inputs` logs a warning
|
||||||
|
/// if inputs arrive while still Pending.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HandshakeState {
|
||||||
|
/// Handshake not yet sent. Inputs arriving in this state trigger a warning.
|
||||||
|
Pending,
|
||||||
|
/// Handshake sent. Normal operation.
|
||||||
|
Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HandshakeState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Receive inputs from bridge and push to InputQueue
|
/// Receive inputs from bridge and push to InputQueue
|
||||||
pub fn receive_bridge_inputs(
|
pub fn receive_bridge_inputs(
|
||||||
bridge: Option<Res<BridgeResource>>,
|
bridge: Option<Res<BridgeResource>>,
|
||||||
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
||||||
mut running: ResMut<ServerRunning>,
|
mut running: ResMut<ServerRunning>,
|
||||||
|
handshake: Res<HandshakeState>,
|
||||||
) {
|
) {
|
||||||
let Some(bridge) = bridge else { return };
|
let Some(bridge) = bridge else { return };
|
||||||
match bridge.receive_inputs() {
|
match bridge.receive_inputs() {
|
||||||
Ok(inputs) => {
|
Ok(inputs) => {
|
||||||
|
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
|
||||||
|
tracing::warn!(
|
||||||
|
"Received {} input(s) before handshake completed — processing anyway (forward-compatible)",
|
||||||
|
inputs.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
for input in &inputs {
|
for input in &inputs {
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
"Received input: tick={} action={:?}",
|
"Received input: tick={} action={:?}",
|
||||||
@@ -156,6 +190,7 @@ impl Plugin for BridgePlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<SnapshotBuffer>()
|
app.init_resource::<SnapshotBuffer>()
|
||||||
.init_resource::<ServerRunning>()
|
.init_resource::<ServerRunning>()
|
||||||
|
.init_resource::<HandshakeState>()
|
||||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||||
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ impl TcpBridge {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Set non-blocking so receive_inputs doesn't stall the game loop.
|
// Set non-blocking so receive_inputs doesn't stall the game loop.
|
||||||
// read_framed handles WouldBlock by returning Ok(None).
|
// receive_inputs catches WouldBlock from read_framed and returns Ok(vec![]).
|
||||||
stream
|
stream
|
||||||
.set_nonblocking(true)
|
.set_nonblocking(true)
|
||||||
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
|
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
|
||||||
@@ -129,6 +129,26 @@ impl TcpBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SimBridge for TcpBridge {
|
impl SimBridge for TcpBridge {
|
||||||
|
fn send_handshake(&self) -> Result<(), BridgeError> {
|
||||||
|
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
|
||||||
|
let msg = HandshakeMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
let payload = rmp_serde::to_vec_named(&msg)?;
|
||||||
|
let mut writer = self
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||||
|
// Toggle to blocking for reliable handshake delivery.
|
||||||
|
let stream = writer.get_mut();
|
||||||
|
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
|
||||||
|
let result = write_framed(stream, &payload);
|
||||||
|
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
|
||||||
|
result?;
|
||||||
|
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||||
let payload = rmp_serde::to_vec_named(snapshot)?;
|
let payload = rmp_serde::to_vec_named(snapshot)?;
|
||||||
|
|
||||||
|
|||||||
@@ -306,11 +306,13 @@ mod tests {
|
|||||||
conversation_events: vec![],
|
conversation_events: vec![],
|
||||||
conversation_ended: vec![],
|
conversation_ended: vec![],
|
||||||
follow_state: None,
|
follow_state: None,
|
||||||
|
character_pressure: None,
|
||||||
sound_events: vec![],
|
sound_events: vec![],
|
||||||
rng_seed: None,
|
rng_seed: None,
|
||||||
poi_list: vec![],
|
poi_list: vec![],
|
||||||
examine_result: None,
|
examine_result: None,
|
||||||
player_knowledge: None,
|
player_knowledge: None,
|
||||||
|
save_result: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,11 +441,13 @@ mod tests {
|
|||||||
conversation_events: vec![],
|
conversation_events: vec![],
|
||||||
conversation_ended: vec![],
|
conversation_ended: vec![],
|
||||||
follow_state: None,
|
follow_state: None,
|
||||||
|
character_pressure: None,
|
||||||
sound_events: vec![],
|
sound_events: vec![],
|
||||||
rng_seed: None,
|
rng_seed: None,
|
||||||
poi_list: vec![],
|
poi_list: vec![],
|
||||||
examine_result: None,
|
examine_result: None,
|
||||||
player_knowledge: None,
|
player_knowledge: None,
|
||||||
|
save_result: None,
|
||||||
};
|
};
|
||||||
let text = format_snapshot_text(&snap);
|
let text = format_snapshot_text(&snap);
|
||||||
assert!(text.contains("Tick 0"));
|
assert!(text.contains("Tick 0"));
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
|||||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||||
/// period, then the default is removed once both sides are updated.
|
/// period, then the default is removed once both sides are updated.
|
||||||
pub const PROTOCOL_VERSION: u8 = 14;
|
pub const PROTOCOL_VERSION: u8 = 15;
|
||||||
|
|
||||||
|
/// Handshake message sent as the very first framed message after connection (#555).
|
||||||
|
/// Client reads this before entering the normal tick loop and validates
|
||||||
|
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
|
||||||
|
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct HandshakeMessage {
|
||||||
|
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
|
||||||
|
pub protocol_version: u8,
|
||||||
|
}
|
||||||
|
|
||||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||||
/// Contains all information visible to the observer at a given tick.
|
/// Contains all information visible to the observer at a given tick.
|
||||||
@@ -37,12 +47,13 @@ pub const PROTOCOL_VERSION: u8 = 14;
|
|||||||
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
|
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
|
||||||
/// follow_state (#241, follow mechanic HUD state).
|
/// follow_state (#241, follow mechanic HUD state).
|
||||||
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
|
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
|
||||||
/// examine_result (#174, character-filtered observation text from Examine verb),
|
/// examine_result (#242, character-filtered examine observation text),
|
||||||
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
|
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
|
||||||
|
/// v15 adds: save_result (#553, save/load operation result for client confirmation).
|
||||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObserverSnapshot {
|
pub struct ObserverSnapshot {
|
||||||
/// Protocol version for forward compatibility. Current: 14.
|
/// Protocol version for forward compatibility. Current: 15.
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
/// Simulation tick when this snapshot was produced
|
/// Simulation tick when this snapshot was produced
|
||||||
pub tick: u64,
|
pub tick: u64,
|
||||||
@@ -110,6 +121,10 @@ pub struct ObserverSnapshot {
|
|||||||
/// Client shows follow indicator with distance, LOS, and tension.
|
/// Client shows follow indicator with distance, LOS, and tension.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
|
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
|
||||||
|
/// Character pressure state for client HUD widget (#248).
|
||||||
|
/// Present when pressure is non-zero. Client renders tension indicator.
|
||||||
|
#[serde(default)]
|
||||||
|
pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>,
|
||||||
/// RNG seed active at this tick for deterministic replay (#527).
|
/// RNG seed active at this tick for deterministic replay (#527).
|
||||||
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
|
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
|
||||||
/// None when the RNG resource is unavailable (should not occur in practice).
|
/// None when the RNG resource is unavailable (should not occur in practice).
|
||||||
@@ -131,6 +146,11 @@ pub struct ObserverSnapshot {
|
|||||||
/// Client renders as a read-only journal grouped by entity.
|
/// Client renders as a read-only journal grouped by entity.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub player_knowledge: Option<PlayerKnowledgeWire>,
|
pub player_knowledge: Option<PlayerKnowledgeWire>,
|
||||||
|
/// Result of the most recently completed save or load (#553, D-085).
|
||||||
|
/// Present for exactly one tick after the operation completes.
|
||||||
|
/// Client shows a confirmation toast (success) or error modal (failure).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub save_result: Option<SaveLoadResultWire>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Game time data for client display (D-031)
|
/// Game time data for client display (D-031)
|
||||||
@@ -405,6 +425,14 @@ pub enum PlayerAction {
|
|||||||
target_entity_id: u64,
|
target_entity_id: u64,
|
||||||
response_id: String,
|
response_id: String,
|
||||||
},
|
},
|
||||||
|
/// Save the current game state to `path` (#553, D-085).
|
||||||
|
/// Client sends this when the player activates the save UI.
|
||||||
|
/// Server executes save_to_file and sends SaveLoadResultWire confirmation.
|
||||||
|
SaveGame { path: String },
|
||||||
|
/// Load a previously saved game from `path` (#553, D-085).
|
||||||
|
/// Client sends this when the player selects a save file to load.
|
||||||
|
/// Server executes load_from_file and sends SaveLoadResultWire confirmation.
|
||||||
|
LoadGame { path: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerAction {
|
impl PlayerAction {
|
||||||
@@ -633,8 +661,65 @@ pub struct KnownFactWire {
|
|||||||
pub acquired_tick: u64,
|
pub acquired_tick: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save/load operation result for client confirmation (#553, D-085).
|
||||||
|
///
|
||||||
|
/// Included in `ObserverSnapshot.save_result` for exactly one tick after the
|
||||||
|
/// operation completes. `success=false` carries a human-readable `error` string.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SaveLoadResultWire {
|
||||||
|
/// Whether the save or load succeeded.
|
||||||
|
pub success: bool,
|
||||||
|
/// "save" or "load" — identifies which operation completed.
|
||||||
|
pub kind: String,
|
||||||
|
/// Error message if `success` is false. None on success.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
pub struct SnapshotBuffer {
|
pub struct SnapshotBuffer {
|
||||||
pub snapshot: Option<ObserverSnapshot>,
|
pub snapshot: Option<ObserverSnapshot>,
|
||||||
|
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
|
||||||
|
pub pending_save_result: Option<SaveLoadResultWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_message_roundtrip() {
|
||||||
|
let msg = HandshakeMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
|
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
assert_eq!(decoded, msg);
|
||||||
|
assert_eq!(decoded.protocol_version, PROTOCOL_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_message_rejects_wrong_version() {
|
||||||
|
let msg = HandshakeMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
|
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||||
|
// Simulate client-side validation: version mismatch should be detectable
|
||||||
|
let wrong_version = PROTOCOL_VERSION.wrapping_add(1);
|
||||||
|
assert_ne!(decoded.protocol_version, wrong_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_is_distinct_from_snapshot() {
|
||||||
|
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
||||||
|
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
|
||||||
|
let msg = HandshakeMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||||
|
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
||||||
|
assert!(result.is_err(), "HandshakeMessage must not deserialize as ObserverSnapshot");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
|||||||
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
||||||
// is authored, add weapon_proficiency + combat_style mapping here.
|
// is authored, add weapon_proficiency + combat_style mapping here.
|
||||||
|
|
||||||
|
// Vision + awareness components (#115, #244) — must match generate_npc().
|
||||||
|
// Without these, vision/awareness systems silently skip content-spawned NPCs.
|
||||||
|
entity_commands.insert((
|
||||||
|
npc::vision::NpcVisionState::default(),
|
||||||
|
npc::vision::NpcMemory::default(),
|
||||||
|
npc::awareness::PlayerAwareness::default(),
|
||||||
|
));
|
||||||
|
|
||||||
let entity = entity_commands.id();
|
let entity = entity_commands.id();
|
||||||
|
|
||||||
// Register in EntityRegistry for StableId mapping
|
// Register in EntityRegistry for StableId mapping
|
||||||
@@ -1100,6 +1108,49 @@ mod tests {
|
|||||||
assert_eq!(edge.trust, 7);
|
assert_eq!(edge.trust, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[test]
|
||||||
|
fn spawn_npc_combat_trained_sets_skill_flag() {
|
||||||
|
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
|
||||||
|
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
|
||||||
|
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
|
||||||
|
// path (generate.rs) correctly attaches CombatCapability. This test documents
|
||||||
|
// current behavior so the gap is visible in CI.
|
||||||
|
let mut world = create_test_world();
|
||||||
|
let mut profile = create_test_profile();
|
||||||
|
profile.skills = Some(NpcSkills {
|
||||||
|
combat_trained: Some(true),
|
||||||
|
skills: Some({
|
||||||
|
let mut m = std::collections::BTreeMap::new();
|
||||||
|
m.insert("combat".to_string(), 7);
|
||||||
|
m
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut result = SpawnResult::default();
|
||||||
|
spawn_npc(&mut world, &profile, &mut result);
|
||||||
|
|
||||||
|
let entity = world
|
||||||
|
.resource::<EntityRegistry>()
|
||||||
|
.to_entity(&result.npc_ids["test-npc"])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
|
||||||
|
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
||||||
|
assert!(
|
||||||
|
skills.combat_trained,
|
||||||
|
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Known gap: CombatCapability not yet attached in content-spawn path.
|
||||||
|
// The procedural path (generate.rs) does attach it — content path has TODO.
|
||||||
|
// Update this assertion when the TODO is resolved.
|
||||||
|
assert!(
|
||||||
|
world.get::<npc::CombatCapability>(entity).is_none(),
|
||||||
|
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_relationships_skips_unknown_targets() {
|
fn resolve_relationships_skips_unknown_targets() {
|
||||||
let mut world = create_test_world();
|
let mut world = create_test_world();
|
||||||
|
|||||||
@@ -83,6 +83,38 @@ impl EntityRegistry {
|
|||||||
self.next_id = target;
|
self.next_id = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register an entity with a specific pre-existing StableId (used during save/load).
|
||||||
|
///
|
||||||
|
/// Unlike `register`, this does NOT advance `next_id`. After bulk-registering
|
||||||
|
/// all restored entities, call `advance_past(max_stable_id)` so future `register()`
|
||||||
|
/// calls produce IDs that don't conflict with the restored set.
|
||||||
|
///
|
||||||
|
/// No-op if the entity is already mapped to the same `stable_id`.
|
||||||
|
/// Panics in debug builds if `stable_id` is already mapped to a different entity.
|
||||||
|
pub fn register_existing(&mut self, entity: Entity, stable_id: StableId) {
|
||||||
|
if let Some(&existing) = self.by_stable_id.get(&stable_id) {
|
||||||
|
debug_assert_eq!(
|
||||||
|
existing, entity,
|
||||||
|
"register_existing: StableId {:?} already mapped to a different entity",
|
||||||
|
stable_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.by_stable_id.insert(stable_id, entity);
|
||||||
|
self.by_entity.insert(entity, stable_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance `next_id` past `id` so future `register()` calls don't conflict.
|
||||||
|
///
|
||||||
|
/// Unlike `reserve_up_to`, this never panics: if the counter is already past `id`,
|
||||||
|
/// this is a no-op. Use after `register_existing` bulk-load to position the counter.
|
||||||
|
pub fn advance_past(&mut self, id: u64) {
|
||||||
|
let target = id.saturating_add(1);
|
||||||
|
if target > self.next_id {
|
||||||
|
self.next_id = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of registered entities.
|
/// Number of registered entities.
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.by_entity.len()
|
self.by_entity.len()
|
||||||
|
|||||||
+13
-2
@@ -11,7 +11,7 @@ use bevy_app::prelude::*;
|
|||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, HandshakeState, ServerRunning};
|
||||||
use settled_reach_server::simulation::SimulationPlugin;
|
use settled_reach_server::simulation::SimulationPlugin;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -121,7 +121,17 @@ fn main() {
|
|||||||
tracing::error!("Failed to accept: {}", e);
|
tracing::error!("Failed to accept: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
tracing::info!("Client connected, initializing simulation");
|
tracing::info!("Client connected, sending protocol handshake");
|
||||||
|
|
||||||
|
// Protocol handshake: first framed message on the wire (#555).
|
||||||
|
// Client reads this and validates protocol_version before sending any input.
|
||||||
|
use settled_reach_server::bridge::SimBridge;
|
||||||
|
bridge.send_handshake().unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to send handshake: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("Handshake sent, initializing simulation");
|
||||||
|
|
||||||
// RNG seed: test-mode defaults to 42 for deterministic replay
|
// RNG seed: test-mode defaults to 42 for deterministic replay
|
||||||
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 });
|
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 });
|
||||||
@@ -138,6 +148,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
app.add_plugins(settled_reach_server::content::ContentPlugin);
|
app.add_plugins(settled_reach_server::content::ContentPlugin);
|
||||||
app.insert_resource(BridgeResource::new(bridge));
|
app.insert_resource(BridgeResource::new(bridge));
|
||||||
|
app.insert_resource(HandshakeState::Complete);
|
||||||
|
|
||||||
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
||||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
//! NPC player-awareness system (#244).
|
||||||
|
//!
|
||||||
|
//! Tracks how aware an NPC is of the player's attention. When the player
|
||||||
|
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
|
||||||
|
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
|
||||||
|
//!
|
||||||
|
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
|
||||||
|
//! Separate from the follow mechanic (#241): follow tracks player-initiated
|
||||||
|
//! proximity, awareness tracks NPC-perceived attention.
|
||||||
|
//!
|
||||||
|
//! ## System ordering
|
||||||
|
//!
|
||||||
|
//! `detect_player_awareness` runs:
|
||||||
|
//! - after `vision::compute_npc_vision` (needs player_visible)
|
||||||
|
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
|
||||||
|
//!
|
||||||
|
//! ## Suspicion lifecycle
|
||||||
|
//!
|
||||||
|
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
|
||||||
|
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
|
||||||
|
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
|
||||||
|
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
|
||||||
|
//!
|
||||||
|
//! All arithmetic is integer-only (D-010 determinism).
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::npc::vision::NpcVisionState;
|
||||||
|
use crate::npc::{Npc, ToleranceThreshold};
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
|
||||||
|
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
|
||||||
|
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
|
||||||
|
|
||||||
|
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
|
||||||
|
/// notices sustained player attention. Lighter than follow stress (2/tick)
|
||||||
|
/// because watching is less intrusive than tailing.
|
||||||
|
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
|
||||||
|
|
||||||
|
/// Ticks between suspicion decay checks when player is NOT in LOS.
|
||||||
|
/// 10 ticks = 1 game-minute (D-031).
|
||||||
|
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
|
||||||
|
|
||||||
|
/// Suspicion decay amount per interval when player is not in LOS.
|
||||||
|
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tracks an NPC's awareness of sustained player attention (#244).
|
||||||
|
///
|
||||||
|
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
|
||||||
|
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
|
||||||
|
///
|
||||||
|
/// ## Fields
|
||||||
|
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
|
||||||
|
/// - `consecutive_los_ticks` — resets when player leaves LOS
|
||||||
|
/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave
|
||||||
|
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerAwareness {
|
||||||
|
/// Whether the player is currently in this NPC's line of sight.
|
||||||
|
pub player_in_los: bool,
|
||||||
|
/// Consecutive ticks the player has been in this NPC's LOS.
|
||||||
|
pub consecutive_los_ticks: u64,
|
||||||
|
/// Accumulated suspicion level (0–100, integer for D-010).
|
||||||
|
pub suspicion_level: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detect sustained player attention and build NPC suspicion.
|
||||||
|
///
|
||||||
|
/// For each Active-tier NPC with `PlayerAwareness`:
|
||||||
|
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
|
||||||
|
/// - If player visible: increment `consecutive_los_ticks`; once past
|
||||||
|
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
|
||||||
|
/// stress to `ToleranceThreshold`
|
||||||
|
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
|
||||||
|
/// `suspicion_level` periodically
|
||||||
|
pub fn detect_player_awareness(
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
mut npc_query: Query<
|
||||||
|
(
|
||||||
|
&NpcVisionState,
|
||||||
|
&mut PlayerAwareness,
|
||||||
|
&mut ToleranceThreshold,
|
||||||
|
),
|
||||||
|
(With<Npc>, With<ActiveSim>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
|
||||||
|
awareness.player_in_los = vision.player_visible;
|
||||||
|
|
||||||
|
if vision.player_visible {
|
||||||
|
awareness.consecutive_los_ticks += 1;
|
||||||
|
|
||||||
|
// Once the NPC has noticed sustained player attention, build suspicion
|
||||||
|
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
|
||||||
|
awareness.suspicion_level =
|
||||||
|
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
|
||||||
|
tolerance.current_stress = tolerance
|
||||||
|
.current_stress
|
||||||
|
.saturating_add(AWARENESS_STRESS_PER_TICK);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Player left LOS — reset consecutive counter
|
||||||
|
awareness.consecutive_los_ticks = 0;
|
||||||
|
|
||||||
|
// Decay suspicion slowly when player is not visible
|
||||||
|
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
|
||||||
|
awareness.suspicion_level =
|
||||||
|
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::npc::vision::NpcVisionState;
|
||||||
|
use crate::npc::{Npc, ToleranceThreshold};
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
|
||||||
|
fn setup_world() -> World {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SimulationTime>();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_system(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(detect_player_awareness);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tolerance() -> ToleranceThreshold {
|
||||||
|
ToleranceThreshold {
|
||||||
|
current_stress: 0,
|
||||||
|
threshold: 80,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vision_with_player(visible: bool) -> NpcVisionState {
|
||||||
|
NpcVisionState {
|
||||||
|
player_visible: visible,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Basic LOS tracking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_in_los_increments_consecutive_ticks() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness::default(),
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert!(awareness.player_in_los);
|
||||||
|
assert_eq!(awareness.consecutive_los_ticks, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_leaving_los_resets_consecutive_ticks() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(false),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: 20,
|
||||||
|
suspicion_level: 5,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert!(!awareness.player_in_los);
|
||||||
|
assert_eq!(awareness.consecutive_los_ticks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_in_los_syncs_from_vision_state() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: false, // was false
|
||||||
|
consecutive_los_ticks: 0,
|
||||||
|
suspicion_level: 0,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert!(awareness.player_in_los, "should sync from NpcVisionState");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Suspicion threshold — no stress before notice ticks
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_stress_before_notice_threshold() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
|
||||||
|
suspicion_level: 0,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level, 0,
|
||||||
|
"suspicion should not build before notice threshold"
|
||||||
|
);
|
||||||
|
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tolerance.current_stress, 0,
|
||||||
|
"stress should not increase before notice threshold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stress_starts_at_notice_threshold() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
|
||||||
|
suspicion_level: 0,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
|
||||||
|
"consecutive ticks should reach threshold"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
|
||||||
|
"suspicion should increase at threshold"
|
||||||
|
);
|
||||||
|
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
|
||||||
|
"stress should increase at threshold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Suspicion accumulation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicion_accumulates_past_threshold() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
|
||||||
|
suspicion_level: 10,
|
||||||
|
},
|
||||||
|
ToleranceThreshold {
|
||||||
|
current_stress: 20,
|
||||||
|
threshold: 80,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level,
|
||||||
|
10 + AWARENESS_STRESS_PER_TICK,
|
||||||
|
"suspicion should accumulate"
|
||||||
|
);
|
||||||
|
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tolerance.current_stress,
|
||||||
|
20 + AWARENESS_STRESS_PER_TICK,
|
||||||
|
"stress should accumulate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicion_caps_at_100() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
|
||||||
|
suspicion_level: 100,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerance_stress_saturates() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
|
||||||
|
suspicion_level: 50,
|
||||||
|
},
|
||||||
|
ToleranceThreshold {
|
||||||
|
current_stress: i16::MAX - 1,
|
||||||
|
threshold: i16::MAX,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tolerance.current_stress,
|
||||||
|
i16::MAX,
|
||||||
|
"stress should saturate, not overflow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Suspicion decay
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicion_decays_when_player_not_in_los_on_interval() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// Set tick to a decay interval boundary
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(false),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: false,
|
||||||
|
consecutive_los_ticks: 0,
|
||||||
|
suspicion_level: 10,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level,
|
||||||
|
10 - AWARENESS_DECAY_AMOUNT,
|
||||||
|
"suspicion should decay on interval tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicion_does_not_decay_off_interval() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// Set tick to a non-interval boundary
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(false),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: false,
|
||||||
|
consecutive_los_ticks: 0,
|
||||||
|
suspicion_level: 10,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level, 10,
|
||||||
|
"suspicion should not decay on non-interval tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspicion_does_not_go_below_zero() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(false),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: false,
|
||||||
|
consecutive_los_ticks: 0,
|
||||||
|
suspicion_level: 0,
|
||||||
|
},
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.suspicion_level, 0,
|
||||||
|
"suspicion should not go below zero"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Background NPC not processed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_npc_not_processed() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
crate::simulation::tier::BackgroundSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness::default(),
|
||||||
|
default_tolerance(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
awareness.consecutive_los_ticks, 0,
|
||||||
|
"background NPC should not have awareness processed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// No panic with missing components
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_without_awareness_no_panic() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
// NPC with vision but no PlayerAwareness — system should skip
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
default_tolerance(),
|
||||||
|
));
|
||||||
|
|
||||||
|
run_system(&mut world); // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Integration: follow + awareness stress stacking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn awareness_stress_stacks_with_existing_stress() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
|
||||||
|
// NPC already has stress from other sources (e.g., follow mechanic)
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
vision_with_player(true),
|
||||||
|
PlayerAwareness {
|
||||||
|
player_in_los: true,
|
||||||
|
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
|
||||||
|
suspicion_level: 5,
|
||||||
|
},
|
||||||
|
ToleranceThreshold {
|
||||||
|
current_stress: 30, // pre-existing stress
|
||||||
|
threshold: 80,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tolerance.current_stress,
|
||||||
|
30 + AWARENESS_STRESS_PER_TICK,
|
||||||
|
"awareness stress should stack with existing stress"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Constant value assertions (#244 spec compliance)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn awareness_constants_have_expected_values() {
|
||||||
|
assert_eq!(
|
||||||
|
AWARENESS_NOTICE_TICKS, 30,
|
||||||
|
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AWARENESS_STRESS_PER_TICK, 1,
|
||||||
|
"#244: stress per tick lighter than follow stress (D-010 integer)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AWARENESS_DECAY_INTERVAL, 10,
|
||||||
|
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AWARENESS_DECAY_AMOUNT, 1,
|
||||||
|
"#244: suspicion decays by 1 per interval"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
//! Background tier state machines (#95, D-026).
|
||||||
|
//!
|
||||||
|
//! Four lightweight state machines for Background-tier NPCs, firing once per
|
||||||
|
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
|
||||||
|
//!
|
||||||
|
//! ## State machines
|
||||||
|
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
|
||||||
|
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
|
||||||
|
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
|
||||||
|
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
|
||||||
|
//!
|
||||||
|
//! ## Design constraints (D-026)
|
||||||
|
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
|
||||||
|
//! - All arithmetic is integer-only (D-010 determinism requirement).
|
||||||
|
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
use crate::npc::{
|
||||||
|
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
|
||||||
|
};
|
||||||
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
|
use crate::npc::routine::ActivityState;
|
||||||
|
use crate::simulation::tier::BackgroundSim;
|
||||||
|
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mood derivation (background-tier)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Derive simplified mood for a Background-tier NPC.
|
||||||
|
///
|
||||||
|
/// No phase or warm-flag considerations — background NPCs have no active
|
||||||
|
/// interactions. Priority order:
|
||||||
|
/// 1. Hostile — stress ≥ threshold
|
||||||
|
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
|
||||||
|
/// 3. Content — stress < 20
|
||||||
|
/// 4. Neutral — otherwise
|
||||||
|
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
|
||||||
|
// 1. Hostile: at or above threshold
|
||||||
|
if current_stress >= threshold {
|
||||||
|
return NpcMood::Hostile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Anxious: 60% of threshold reached
|
||||||
|
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
|
||||||
|
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
|
||||||
|
return NpcMood::Anxious;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Content: low stress
|
||||||
|
if current_stress < 20 {
|
||||||
|
return NpcMood::Content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Neutral: default
|
||||||
|
NpcMood::Neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job performance drift
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Drift job performance score one point per game-minute based on contentment.
|
||||||
|
///
|
||||||
|
/// - contentment > 0 → score + 1 (clamped at 100)
|
||||||
|
/// - contentment < 0 → score - 1 (clamped at 0)
|
||||||
|
/// - contentment = 0 → unchanged
|
||||||
|
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
|
||||||
|
match contentment_level.cmp(&0) {
|
||||||
|
std::cmp::Ordering::Greater => (score + 1).min(100),
|
||||||
|
std::cmp::Ordering::Less => (score - 1).max(0),
|
||||||
|
std::cmp::Ordering::Equal => score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System: background_tick
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// System: run all four background-tier state machines once per game-minute.
|
||||||
|
///
|
||||||
|
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
|
||||||
|
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
|
||||||
|
/// per-tick systems.
|
||||||
|
///
|
||||||
|
/// Machine execution order per NPC:
|
||||||
|
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
|
||||||
|
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
|
||||||
|
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
|
||||||
|
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
|
||||||
|
///
|
||||||
|
/// Commands for `ActivityState` are deferred (applied after system runs).
|
||||||
|
/// Mutable component mutations happen immediately within the iteration.
|
||||||
|
pub fn background_tick(
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut query: Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
Option<&ActivityState>,
|
||||||
|
&mut MoodState,
|
||||||
|
Option<&mut Relationships>,
|
||||||
|
Option<&ToleranceThreshold>,
|
||||||
|
Option<&DailyRoutine>,
|
||||||
|
Option<&Contentment>,
|
||||||
|
Option<&mut JobPerformance>,
|
||||||
|
),
|
||||||
|
(With<Npc>, With<BackgroundSim>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
// Fire once per game-minute (D-031: 10 ticks/minute)
|
||||||
|
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase = time.day_phase();
|
||||||
|
let tick = time.tick;
|
||||||
|
|
||||||
|
for (
|
||||||
|
entity,
|
||||||
|
activity_opt,
|
||||||
|
mut mood_state,
|
||||||
|
rels_opt,
|
||||||
|
tolerance_opt,
|
||||||
|
routine_opt,
|
||||||
|
contentment_opt,
|
||||||
|
job_opt,
|
||||||
|
) in query.iter_mut()
|
||||||
|
{
|
||||||
|
// --- 1. Schedule: sync ActivityState to current DayPhase ---
|
||||||
|
// Background NPCs don't pathfind — we directly declare the activity.
|
||||||
|
if let Some(routine) = routine_opt {
|
||||||
|
if let Some(entry) = routine.entry_for_phase(phase) {
|
||||||
|
let needs_update = match activity_opt {
|
||||||
|
Some(a) => a.phase != phase || a.activity != entry.activity,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if needs_update {
|
||||||
|
commands.entity(entity).insert(ActivityState {
|
||||||
|
activity: entry.activity.clone(),
|
||||||
|
phase,
|
||||||
|
started_tick: tick,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if activity_opt.is_some() {
|
||||||
|
// No routine entry for this phase — clear stale activity
|
||||||
|
commands.entity(entity).remove::<ActivityState>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Mood: simplified stress-based derivation ---
|
||||||
|
let (stress, threshold) = tolerance_opt
|
||||||
|
.map(|t| (t.current_stress, t.threshold))
|
||||||
|
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
|
||||||
|
let new_mood = derive_background_mood(stress, threshold);
|
||||||
|
if mood_state.mood != new_mood {
|
||||||
|
mood_state.mood = new_mood;
|
||||||
|
mood_state.changed_tick = tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Relationships: trust drift toward 0 (baseline) ---
|
||||||
|
if let Some(mut rels) = rels_opt {
|
||||||
|
for rel in &mut rels.entries {
|
||||||
|
if rel.trust_level > 0 {
|
||||||
|
rel.trust_level -= 1;
|
||||||
|
} else if rel.trust_level < 0 {
|
||||||
|
rel.trust_level += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Job: performance drift from contentment ---
|
||||||
|
if let Some(mut job) = job_opt {
|
||||||
|
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
|
||||||
|
job.score = drift_job_performance(job.score, contentment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::npc::{
|
||||||
|
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
|
||||||
|
Relationships, RoutineEntry, ToleranceThreshold,
|
||||||
|
};
|
||||||
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
|
use crate::npc::routine::ActivityState;
|
||||||
|
use crate::simulation::movement::TilePosition;
|
||||||
|
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||||
|
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
|
||||||
|
// --- derive_background_mood ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_hostile_at_threshold() {
|
||||||
|
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_hostile_above_threshold() {
|
||||||
|
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_anxious_at_60_percent() {
|
||||||
|
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
|
||||||
|
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_anxious_boundary_below_threshold() {
|
||||||
|
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
|
||||||
|
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_content_low_stress() {
|
||||||
|
// stress=10 < 20 → Content (not hostile, not anxious)
|
||||||
|
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_neutral_moderate_stress() {
|
||||||
|
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
|
||||||
|
// not content (25 >= 20) → Neutral
|
||||||
|
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_zero_threshold_is_hostile() {
|
||||||
|
// stress=0 >= threshold=0 → Hostile
|
||||||
|
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_mood_zero_stress_moderate_threshold_is_content() {
|
||||||
|
// stress=0 < 20 → Content
|
||||||
|
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drift_job_performance ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_drift_up_when_positive_contentment() {
|
||||||
|
assert_eq!(drift_job_performance(50, 10), 51);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_drift_down_when_negative_contentment() {
|
||||||
|
assert_eq!(drift_job_performance(50, -10), 49);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_drift_unchanged_at_zero_contentment() {
|
||||||
|
assert_eq!(drift_job_performance(50, 0), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_drift_clamps_at_100() {
|
||||||
|
assert_eq!(drift_job_performance(100, 5), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_drift_clamps_at_0() {
|
||||||
|
assert_eq!(drift_job_performance(0, -5), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- background_tick system integration tests ---
|
||||||
|
|
||||||
|
fn setup_world() -> World {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SimulationTime>();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_system(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(background_tick);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
|
||||||
|
DailyRoutine {
|
||||||
|
entries: vec![RoutineEntry {
|
||||||
|
phase,
|
||||||
|
location: TilePosition::new(5, 5, 0),
|
||||||
|
activity: activity.to_string(),
|
||||||
|
}],
|
||||||
|
description: "Test routine".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tick gating ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_fire_on_non_minute_tick() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 5;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
// Mood should NOT have updated (Hostile would fire if it ran)
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fires_at_tick_zero() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// tick=0 is 0 % 10 == 0, so it fires
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Active-tier NPCs not processed ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_npcs_not_processed() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
// ActiveSim NPC — must NOT be processed by background_tick
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
MoodState {
|
||||||
|
mood: NpcMood::Warm,
|
||||||
|
changed_tick: 0,
|
||||||
|
},
|
||||||
|
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mood state machine ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_hostile_when_stress_at_threshold() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 50, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_content_when_no_tolerance() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, BackgroundSim, MoodState::default()))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_records_changed_tick() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
|
||||||
|
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||||
|
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_unchanged_tick_not_updated() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
// Already Content, will derive Content → no change to changed_tick
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState { mood: NpcMood::Content, changed_tick: 42 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mood = world.get::<MoodState>(npc).unwrap();
|
||||||
|
assert_eq!(mood.mood, NpcMood::Content);
|
||||||
|
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedule state machine ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedule_sets_activity_state_for_current_phase() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// tick=0 → DayPhase::Morning
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
make_routine(DayPhase::Morning, "Work"),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||||
|
assert_eq!(activity.activity, "Work");
|
||||||
|
assert_eq!(activity.phase, DayPhase::Morning);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedule_no_activity_when_no_routine_entry_for_phase() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
// tick=0 → Morning, but routine only has Afternoon
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
make_routine(DayPhase::Afternoon, "Meeting"),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
// No ActivityState should be inserted (Morning has no entry)
|
||||||
|
assert!(world.get::<ActivityState>(npc).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedule_preserves_correct_activity_state() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0; // Morning
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
make_routine(DayPhase::Morning, "Work"),
|
||||||
|
// Already has correct ActivityState — should not be re-inserted
|
||||||
|
ActivityState {
|
||||||
|
activity: "Work".into(),
|
||||||
|
phase: DayPhase::Morning,
|
||||||
|
started_tick: 0,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||||
|
assert_eq!(activity.activity, "Work");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Relationships state machine ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relationships_positive_trust_drifts_toward_zero() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
Relationships {
|
||||||
|
entries: vec![Relationship {
|
||||||
|
target_id: StableId(1),
|
||||||
|
kind: RelationshipKind::Friend,
|
||||||
|
trust_level: 5,
|
||||||
|
history: vec![],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let rels = world.get::<Relationships>(npc).unwrap();
|
||||||
|
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relationships_negative_trust_drifts_toward_zero() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
Relationships {
|
||||||
|
entries: vec![Relationship {
|
||||||
|
target_id: StableId(2),
|
||||||
|
kind: RelationshipKind::Rival,
|
||||||
|
trust_level: -4,
|
||||||
|
history: vec![],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let rels = world.get::<Relationships>(npc).unwrap();
|
||||||
|
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relationships_zero_trust_stays_zero() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
Relationships {
|
||||||
|
entries: vec![Relationship {
|
||||||
|
target_id: StableId(3),
|
||||||
|
kind: RelationshipKind::Colleague,
|
||||||
|
trust_level: 0,
|
||||||
|
history: vec![],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let rels = world.get::<Relationships>(npc).unwrap();
|
||||||
|
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Job state machine ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_performance_rises_with_positive_contentment() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
JobPerformance { score: 60 },
|
||||||
|
Contentment { level: 20 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||||
|
assert_eq!(job.score, 61);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_performance_falls_with_negative_contentment() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
JobPerformance { score: 60 },
|
||||||
|
Contentment { level: -15 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||||
|
assert_eq!(job.score, 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_performance_unchanged_at_zero_contentment() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
JobPerformance { score: 50 },
|
||||||
|
Contentment { level: 0 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||||
|
assert_eq!(job.score, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_performance_without_component_no_panic() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
// No JobPerformance — system must not panic
|
||||||
|
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
|
||||||
|
|
||||||
|
run_system(&mut world); // must not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multiple NPCs independent ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_background_npcs_processed_independently() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 0;
|
||||||
|
|
||||||
|
let calm = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 5, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let hostile = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
BackgroundSim,
|
||||||
|
MoodState::default(),
|
||||||
|
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
|
||||||
|
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
|
|||||||
|
|
||||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
||||||
use crate::npc::{
|
use crate::npc::{
|
||||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
|
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
|
||||||
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
|
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
|
||||||
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
|
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
|
||||||
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||||
};
|
};
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
use crate::npc::mood::MoodState;
|
use crate::npc::mood::MoodState;
|
||||||
|
use crate::npc::awareness::PlayerAwareness;
|
||||||
|
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||||
use crate::simulation::movement::TilePosition;
|
use crate::simulation::movement::TilePosition;
|
||||||
use crate::simulation::rng::SimRng;
|
use crate::simulation::rng::SimRng;
|
||||||
use crate::simulation::tier::ActiveSim;
|
use crate::simulation::tier::ActiveSim;
|
||||||
@@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
|
|||||||
|
|
||||||
DailyRoutine {
|
DailyRoutine {
|
||||||
entries,
|
entries,
|
||||||
description: format!("Routine schedule"),
|
description: "Routine schedule".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
|
|||||||
tells,
|
tells,
|
||||||
skills,
|
skills,
|
||||||
MoodState::default(),
|
MoodState::default(),
|
||||||
|
JobPerformance::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Vision + knowledge + awareness components (#115, #244, D-041).
|
||||||
|
// Separate insert to stay within tuple bundle element limit.
|
||||||
|
entity_builder.insert((
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
NpcVisionState::default(),
|
||||||
|
NpcMemory::default(),
|
||||||
|
PlayerAwareness::default(),
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Some(cap) = combat_opt {
|
if let Some(cap) = combat_opt {
|
||||||
@@ -876,6 +889,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Combat capability — positive case (#91, D-024)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combat_role_probabilistically_produces_combat_capability() {
|
||||||
|
// #91 positive-case: at least one seed must produce CombatCapability for
|
||||||
|
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
|
||||||
|
// so 30 seeds is overwhelmingly likely to produce at least one match.
|
||||||
|
let role = minimal_role(); // combat_enabled = true
|
||||||
|
let mut any_combat = false;
|
||||||
|
for seed in 0..30_u64 {
|
||||||
|
let mut world = World::new();
|
||||||
|
let mut rng = make_rng(seed);
|
||||||
|
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||||
|
if world.get::<CombatCapability>(entity).is_some() {
|
||||||
|
any_combat = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
any_combat,
|
||||||
|
"combat-enabled role must produce CombatCapability for at least one seed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combat_trained_flag_matches_combat_capability_presence() {
|
||||||
|
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
|
||||||
|
// If CombatCapability is present, combat_trained must be true and vice versa.
|
||||||
|
let role = minimal_role(); // combat_enabled = true
|
||||||
|
for seed in 0..50_u64 {
|
||||||
|
let mut world = World::new();
|
||||||
|
let mut rng = make_rng(seed);
|
||||||
|
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||||
|
let skills = world.get::<SkillSet>(entity).unwrap();
|
||||||
|
let has_cap = world.get::<CombatCapability>(entity).is_some();
|
||||||
|
assert_eq!(
|
||||||
|
skills.combat_trained, has_cap,
|
||||||
|
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
|
||||||
|
skills.combat_trained, has_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combat_capability_proficiency_in_valid_range() {
|
||||||
|
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
|
||||||
|
let role = minimal_role(); // combat_enabled = true
|
||||||
|
for seed in 0..100_u64 {
|
||||||
|
let mut world = World::new();
|
||||||
|
let mut rng = make_rng(seed);
|
||||||
|
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||||
|
if let Some(cap) = world.get::<CombatCapability>(entity) {
|
||||||
|
assert!(
|
||||||
|
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
|
||||||
|
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
|
||||||
|
cap.weapon_proficiency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// ActiveSim tier
|
// ActiveSim tier
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||||
// Background tier state machines for schedule, mood, relationships, job
|
// Background tier state machines for schedule, mood, relationships, job
|
||||||
|
|
||||||
|
pub mod awareness;
|
||||||
|
pub mod background;
|
||||||
pub mod disclosure;
|
pub mod disclosure;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
@@ -11,6 +13,7 @@ pub mod routine;
|
|||||||
pub mod tell_state;
|
pub mod tell_state;
|
||||||
pub mod tolerance;
|
pub mod tolerance;
|
||||||
pub mod trait_modifiers;
|
pub mod trait_modifiers;
|
||||||
|
pub mod vision;
|
||||||
|
|
||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<relationships::RelationshipGraph>()
|
app.init_resource::<relationships::RelationshipGraph>()
|
||||||
.init_resource::<relationships::TrustEventQueue>()
|
.init_resource::<relationships::TrustEventQueue>()
|
||||||
|
.init_resource::<relationships::PropagationQueue>()
|
||||||
|
.init_resource::<relationships::DelayedTrustQueue>()
|
||||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||||
.init_resource::<routine::PreviousDayPhase>()
|
.init_resource::<routine::PreviousDayPhase>()
|
||||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||||
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
|
|||||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||||
.before(crate::simulation::time::advance_tick),
|
.before(crate::simulation::time::advance_tick),
|
||||||
|
relationships::propagate_social_actions
|
||||||
|
.after(relationships::update_trust)
|
||||||
|
.before(crate::simulation::time::advance_tick),
|
||||||
relationships::update_relationship_dynamics
|
relationships::update_relationship_dynamics
|
||||||
.after(relationships::update_trust)
|
.after(relationships::update_trust)
|
||||||
.before(crate::simulation::time::advance_tick),
|
.before(crate::simulation::time::advance_tick),
|
||||||
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
|
|||||||
.after(mood::update_mood)
|
.after(mood::update_mood)
|
||||||
.after(routine::detect_routine_deviation)
|
.after(routine::detect_routine_deviation)
|
||||||
.before(crate::perception::observer::compute_observer_snapshot),
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
|
background::background_tick
|
||||||
|
.after(crate::simulation::movement::validate_movement)
|
||||||
|
.before(crate::simulation::time::advance_tick),
|
||||||
disclosure::derive_disclosure_candidates
|
disclosure::derive_disclosure_candidates
|
||||||
.before(crate::perception::observer::compute_observer_snapshot),
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
disclosure::process_unprompted_disclosure
|
disclosure::process_unprompted_disclosure
|
||||||
.after(disclosure::derive_disclosure_candidates)
|
.after(disclosure::derive_disclosure_candidates)
|
||||||
.before(crate::perception::observer::compute_observer_snapshot),
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
|
// NPC player-awareness (#244)
|
||||||
|
awareness::detect_player_awareness
|
||||||
|
.after(vision::compute_npc_vision)
|
||||||
|
.before(tolerance::check_tolerance_threshold),
|
||||||
|
// NPC vision system (#115, D-011)
|
||||||
|
vision::compute_npc_vision
|
||||||
|
.after(crate::simulation::movement::validate_movement)
|
||||||
|
.after(crate::simulation::tier::update_tier_markers)
|
||||||
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
|
vision::emit_npc_vision_events
|
||||||
|
.after(vision::compute_npc_vision)
|
||||||
|
.before(crate::knowledge::events::process_knowledge_events),
|
||||||
|
vision::degrade_npc_inferences
|
||||||
|
.after(vision::emit_npc_vision_events)
|
||||||
|
.before(crate::simulation::time::advance_tick),
|
||||||
crate::simulation::dialogue::process_talk_interaction
|
crate::simulation::dialogue::process_talk_interaction
|
||||||
.after(crate::simulation::input::process_player_input),
|
.after(crate::simulation::input::process_player_input),
|
||||||
crate::simulation::dialogue::process_walk_away
|
crate::simulation::dialogue::process_walk_away
|
||||||
@@ -268,6 +294,30 @@ pub struct Contentment {
|
|||||||
pub level: i16, // -100..+100, integer for determinism (D-010)
|
pub level: i16, // -100..+100, integer for determinism (D-010)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job performance (D-026 background state machine — feeds from Contentment)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tracks how well an NPC performs their job role.
|
||||||
|
///
|
||||||
|
/// Drifts based on `Contentment` level in the background-tier state machine:
|
||||||
|
/// - contentment > 0 → score increases toward 100
|
||||||
|
/// - contentment < 0 → score decreases toward 0
|
||||||
|
/// - contentment = 0 → no change
|
||||||
|
///
|
||||||
|
/// Updated by `background::background_tick` once per game-minute for
|
||||||
|
/// Background-tier NPCs. Persists through tier promotions (D-026).
|
||||||
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JobPerformance {
|
||||||
|
pub score: i16, // 0..=100, integer for determinism (D-010)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JobPerformance {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { score: 50 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Supporting axis 1: Personality traits (D-024)
|
// Supporting axis 1: Personality traits (D-024)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ impl RelationshipGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all entities who have feelings about a target.
|
/// Get all entities who have feelings about a target.
|
||||||
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
|
/// O(N) full scan of all edges. Called once per game-minute (every 10 ticks)
|
||||||
|
/// by the pressure system — acceptable at v0.1 NPC counts.
|
||||||
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||||
self.edges
|
self.edges
|
||||||
.iter()
|
.iter()
|
||||||
@@ -201,16 +202,121 @@ impl RelationshipGraph {
|
|||||||
// System: update_trust (#324)
|
// System: update_trust (#324)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Social propagation (#249, D-029)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Minimum absolute trust required on a relationship edge for that edge
|
||||||
|
/// to carry propagation (D-029: "strong relationships", trust > 3).
|
||||||
|
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
|
||||||
|
|
||||||
|
/// Delay in ticks before third-order propagation is applied.
|
||||||
|
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
|
||||||
|
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
|
||||||
|
|
||||||
|
/// A first-order trust change that needs social propagation.
|
||||||
|
///
|
||||||
|
/// Produced by `update_trust` for each processed event, consumed by
|
||||||
|
/// `propagate_social_actions` to fan out second- and third-order effects.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PropagationEvent {
|
||||||
|
/// The NPC directly affected by the player action (first-order subject).
|
||||||
|
pub npc: StableId,
|
||||||
|
/// The player entity (target of how NPCs feel).
|
||||||
|
pub player: StableId,
|
||||||
|
/// The first-order delta (same as what was applied to the graph).
|
||||||
|
pub delta: i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue of propagation events emitted by `update_trust`.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct PropagationQueue {
|
||||||
|
events: Vec<PropagationEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PropagationQueue {
|
||||||
|
pub fn push(&mut self, event: PropagationEvent) {
|
||||||
|
self.events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain(&mut self) -> Vec<PropagationEvent> {
|
||||||
|
std::mem::take(&mut self.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deferred trust change for third-order propagation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DelayedTrustChange {
|
||||||
|
/// NPC whose trust toward the player will be adjusted.
|
||||||
|
pub npc: StableId,
|
||||||
|
/// Player entity.
|
||||||
|
pub player: StableId,
|
||||||
|
/// Scaled delta to apply (already clamped to meaningful range).
|
||||||
|
pub delta: i8,
|
||||||
|
/// Tick at which this change should be applied.
|
||||||
|
pub apply_at_tick: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue of deferred third-order trust changes.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct DelayedTrustQueue {
|
||||||
|
pending: Vec<DelayedTrustChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DelayedTrustQueue {
|
||||||
|
pub fn push(&mut self, change: DelayedTrustChange) {
|
||||||
|
self.pending.push(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain changes that are due at or before `current_tick`.
|
||||||
|
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
|
||||||
|
let mut due = Vec::new();
|
||||||
|
let mut remaining = Vec::new();
|
||||||
|
for change in self.pending.drain(..) {
|
||||||
|
if change.apply_at_tick <= current_tick {
|
||||||
|
due.push(change);
|
||||||
|
} else {
|
||||||
|
remaining.push(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pending = remaining;
|
||||||
|
due
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of pending deferred changes.
|
||||||
|
pub fn pending_count(&self) -> usize {
|
||||||
|
self.pending.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale a first-order delta by a propagation factor, using integer arithmetic
|
||||||
|
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
||||||
|
/// round to zero — small deltas naturally attenuate to nothing.
|
||||||
|
///
|
||||||
|
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
|
||||||
|
///
|
||||||
|
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
||||||
|
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
||||||
|
// Multiply by factor, round up (ceiling of absolute value)
|
||||||
|
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
|
||||||
|
let scaled = scaled_abs.min(10) as i8;
|
||||||
|
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
||||||
///
|
///
|
||||||
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
||||||
/// one is created with default Colleague kind and trust 0 before applying
|
/// one is created with default Colleague kind and trust 0 before applying
|
||||||
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
||||||
///
|
///
|
||||||
|
/// Also queues a `PropagationEvent` for each processed event so that
|
||||||
|
/// `propagate_social_actions` can fan out second- and third-order effects.
|
||||||
|
///
|
||||||
/// System ordering: after dialogue systems (which emit the events),
|
/// System ordering: after dialogue systems (which emit the events),
|
||||||
/// before advance_tick.
|
/// before advance_tick.
|
||||||
pub fn update_trust(
|
pub fn update_trust(
|
||||||
mut queue: ResMut<TrustEventQueue>,
|
mut queue: ResMut<TrustEventQueue>,
|
||||||
|
mut propagation_queue: ResMut<PropagationQueue>,
|
||||||
mut graph: ResMut<RelationshipGraph>,
|
mut graph: ResMut<RelationshipGraph>,
|
||||||
registry: Res<EntityRegistry>,
|
registry: Res<EntityRegistry>,
|
||||||
time: Res<SimulationTime>,
|
time: Res<SimulationTime>,
|
||||||
@@ -244,6 +350,116 @@ pub fn update_trust(
|
|||||||
new_trust = edge.trust,
|
new_trust = edge.trust,
|
||||||
"Trust updated"
|
"Trust updated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Queue propagation event for second/third-order effects (#249)
|
||||||
|
propagation_queue.push(PropagationEvent {
|
||||||
|
npc: npc_sid,
|
||||||
|
player: player_sid,
|
||||||
|
delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System: fan out player-action trust changes through the social graph (#249, D-029).
|
||||||
|
///
|
||||||
|
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
|
||||||
|
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
|
||||||
|
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
|
||||||
|
///
|
||||||
|
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
|
||||||
|
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
|
||||||
|
/// the `RelationshipGraph` is seeded differently per run.
|
||||||
|
///
|
||||||
|
/// System ordering: after `update_trust`, before `advance_tick`.
|
||||||
|
pub fn propagate_social_actions(
|
||||||
|
mut propagation_queue: ResMut<PropagationQueue>,
|
||||||
|
mut delay_queue: ResMut<DelayedTrustQueue>,
|
||||||
|
mut graph: ResMut<RelationshipGraph>,
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
) {
|
||||||
|
// --- Apply any due delayed third-order changes first ---
|
||||||
|
for change in delay_queue.drain_due(time.tick) {
|
||||||
|
if change.delta != 0 {
|
||||||
|
graph.ensure_edge(change.npc, change.player, time.tick);
|
||||||
|
graph.adjust_trust(&change.npc, &change.player, change.delta);
|
||||||
|
tracing::trace!(
|
||||||
|
npc = change.npc.0,
|
||||||
|
player = change.player.0,
|
||||||
|
delta = change.delta,
|
||||||
|
"Third-order trust propagated (delayed)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fan out new propagation events ---
|
||||||
|
for event in propagation_queue.drain() {
|
||||||
|
let PropagationEvent { npc, player, delta } = event;
|
||||||
|
|
||||||
|
// Visited set prevents cycles (D-029)
|
||||||
|
let mut visited = std::collections::BTreeSet::new();
|
||||||
|
visited.insert(npc);
|
||||||
|
|
||||||
|
// --- Second-order (immediate, 40% of delta) ---
|
||||||
|
let second_delta = scale_delta(delta, 4); // 40%
|
||||||
|
if second_delta != 0 {
|
||||||
|
// Find NPCs that the first-order NPC trusts strongly
|
||||||
|
let second_order: Vec<StableId> = graph
|
||||||
|
.relationships_of(&npc)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||||
|
.map(|(target, _)| *target)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for second in &second_order {
|
||||||
|
if visited.contains(second) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.insert(*second);
|
||||||
|
graph.ensure_edge(*second, player, time.tick);
|
||||||
|
graph.adjust_trust(second, &player, second_delta);
|
||||||
|
tracing::trace!(
|
||||||
|
first_order_npc = npc.0,
|
||||||
|
second_order_npc = second.0,
|
||||||
|
player = player.0,
|
||||||
|
delta = second_delta,
|
||||||
|
"Second-order trust propagated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Third-order (delayed 30 ticks, 15% of delta) ---
|
||||||
|
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
|
||||||
|
if third_delta != 0 {
|
||||||
|
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
|
||||||
|
for second in &second_order {
|
||||||
|
let third_order: Vec<StableId> = graph
|
||||||
|
.relationships_of(second)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||||
|
.map(|(target, _)| *target)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for third in third_order {
|
||||||
|
if visited.contains(&third) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.insert(third);
|
||||||
|
delay_queue.push(DelayedTrustChange {
|
||||||
|
npc: third,
|
||||||
|
player,
|
||||||
|
delta: third_delta,
|
||||||
|
apply_at_tick: apply_at,
|
||||||
|
});
|
||||||
|
tracing::trace!(
|
||||||
|
third_order_npc = third.0,
|
||||||
|
player = player.0,
|
||||||
|
delta = third_delta,
|
||||||
|
apply_at,
|
||||||
|
"Third-order trust queued for delayed propagation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +709,7 @@ mod tests {
|
|||||||
world.init_resource::<EntityRegistry>();
|
world.init_resource::<EntityRegistry>();
|
||||||
world.init_resource::<RelationshipGraph>();
|
world.init_resource::<RelationshipGraph>();
|
||||||
world.init_resource::<TrustEventQueue>();
|
world.init_resource::<TrustEventQueue>();
|
||||||
|
world.init_resource::<PropagationQueue>();
|
||||||
world
|
world
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +965,380 @@ mod tests {
|
|||||||
assert_eq!(edge.trust, 6); // 5 + 1
|
assert_eq!(edge.trust, 6); // 5 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- PropagationQueue tests (#249) ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagation_queue_push_and_drain() {
|
||||||
|
let mut q = PropagationQueue::default();
|
||||||
|
q.push(PropagationEvent {
|
||||||
|
npc: StableId(1),
|
||||||
|
player: StableId(2),
|
||||||
|
delta: 1,
|
||||||
|
});
|
||||||
|
q.push(PropagationEvent {
|
||||||
|
npc: StableId(3),
|
||||||
|
player: StableId(2),
|
||||||
|
delta: -2,
|
||||||
|
});
|
||||||
|
let drained = q.drain();
|
||||||
|
assert_eq!(drained.len(), 2);
|
||||||
|
assert!(q.drain().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delayed_trust_queue_drain_due_filters_by_tick() {
|
||||||
|
let mut q = DelayedTrustQueue::default();
|
||||||
|
q.push(DelayedTrustChange {
|
||||||
|
npc: StableId(1),
|
||||||
|
player: StableId(10),
|
||||||
|
delta: 1,
|
||||||
|
apply_at_tick: 50,
|
||||||
|
});
|
||||||
|
q.push(DelayedTrustChange {
|
||||||
|
npc: StableId(2),
|
||||||
|
player: StableId(10),
|
||||||
|
delta: -1,
|
||||||
|
apply_at_tick: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only change at tick 50 is due at tick 60
|
||||||
|
let due = q.drain_due(60);
|
||||||
|
assert_eq!(due.len(), 1);
|
||||||
|
assert_eq!(due[0].npc, StableId(1));
|
||||||
|
// Change at tick 100 is still pending
|
||||||
|
assert_eq!(q.pending_count(), 1);
|
||||||
|
|
||||||
|
// At tick 100 it becomes due
|
||||||
|
let due2 = q.drain_due(100);
|
||||||
|
assert_eq!(due2.len(), 1);
|
||||||
|
assert_eq!(due2[0].npc, StableId(2));
|
||||||
|
assert_eq!(q.pending_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_delta_forty_percent() {
|
||||||
|
// factor_tenths=4 → 40%
|
||||||
|
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
|
||||||
|
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
|
||||||
|
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
|
||||||
|
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
|
||||||
|
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_delta_zero_when_too_small() {
|
||||||
|
// delta=0 → always 0
|
||||||
|
assert_eq!(scale_delta(0, 4), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- propagate_social_actions system tests (#249) -------------------------
|
||||||
|
|
||||||
|
fn setup_propagation_world() -> bevy_ecs::world::World {
|
||||||
|
let mut world = bevy_ecs::world::World::new();
|
||||||
|
world.init_resource::<SimulationTime>();
|
||||||
|
world.init_resource::<RelationshipGraph>();
|
||||||
|
world.init_resource::<PropagationQueue>();
|
||||||
|
world.init_resource::<DelayedTrustQueue>();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_order_trust_propagates_immediately() {
|
||||||
|
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
|
||||||
|
// receive 40% of the delta in the same tick.
|
||||||
|
//
|
||||||
|
// Graph: A → B (trust 5, > threshold 3)
|
||||||
|
// A → player (will be first-order)
|
||||||
|
// Event: player action affects A (delta=+2)
|
||||||
|
// Expected: B gains 40% of 2 = 1 (ceil) toward player
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
// A strongly trusts B (A→B trust=5)
|
||||||
|
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||||
|
npc_a,
|
||||||
|
npc_b,
|
||||||
|
make_edge(RelationshipKind::Friend, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queue propagation from A
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// B should now have a trust edge toward the player (positive)
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
|
||||||
|
assert!(
|
||||||
|
edge.trust > 0,
|
||||||
|
"Second-order NPC should trust player more after positive first-order event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weak_relationship_does_not_propagate() {
|
||||||
|
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
|
||||||
|
//
|
||||||
|
// Graph: A → B (trust 2, ≤ threshold 3)
|
||||||
|
// Event: player action affects A (delta=+5)
|
||||||
|
// Expected: B gets no propagation (trust ≤ threshold)
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
// A weakly trusts B (below threshold)
|
||||||
|
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||||
|
npc_a,
|
||||||
|
npc_b,
|
||||||
|
make_edge(RelationshipKind::Colleague, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// B should NOT have any edge to the player
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
assert!(
|
||||||
|
graph.get_relationship(&npc_b, &player).is_none(),
|
||||||
|
"Weak relationship should not carry propagation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn third_order_trust_is_deferred_by_thirty_ticks() {
|
||||||
|
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
|
||||||
|
//
|
||||||
|
// Graph: A → B (trust 5), B → C (trust 5)
|
||||||
|
// Event: player action affects A (delta=+5)
|
||||||
|
// Expected: C's change is queued for tick+30, not applied immediately
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let npc_c = StableId(3);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||||
|
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||||
|
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||||
|
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// C should NOT have an edge yet (it's deferred)
|
||||||
|
{
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
assert!(
|
||||||
|
graph.get_relationship(&npc_c, &player).is_none(),
|
||||||
|
"Third-order changes should be deferred, not applied immediately"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay queue should have one pending change for C at tick 10+30=40
|
||||||
|
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||||
|
assert_eq!(delay_queue.pending_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delayed_changes_applied_when_due() {
|
||||||
|
// Once the tick advances past apply_at_tick, the delayed change is applied.
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let npc_c = StableId(3);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||||
|
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||||
|
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||||
|
|
||||||
|
// First run: queue the third-order change
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// Advance tick to 40 (past apply_at_tick = 40)
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 40;
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// C should now have an edge with positive trust
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
let edge = graph.get_relationship(&npc_c, &player)
|
||||||
|
.expect("Delayed change should have been applied by now");
|
||||||
|
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_prevention_no_a_to_b_to_a_loop() {
|
||||||
|
// Spec (#249, D-029): visited set prevents A→B→A cycles.
|
||||||
|
//
|
||||||
|
// Graph: A ↔ B (both trust each other at 5)
|
||||||
|
// Event: player action affects A (delta=+2)
|
||||||
|
// A should propagate to B, but B should NOT propagate back to A.
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
// Bidirectional strong trust
|
||||||
|
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||||
|
npc_a,
|
||||||
|
npc_b,
|
||||||
|
make_edge(RelationshipKind::Friend, 5),
|
||||||
|
);
|
||||||
|
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||||
|
npc_b,
|
||||||
|
npc_a,
|
||||||
|
make_edge(RelationshipKind::Friend, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// A should not get a second-order change from the cycle (A was the origin)
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
// The only A→player edge effect should be through the propagation event
|
||||||
|
// (no direct creation in propagate_social_actions — only update_trust does that).
|
||||||
|
// B should have an edge to player.
|
||||||
|
assert!(
|
||||||
|
graph.get_relationship(&npc_b, &player).is_some(),
|
||||||
|
"B should get second-order propagation from A"
|
||||||
|
);
|
||||||
|
// A should NOT have a re-propagated edge (cycle prevented)
|
||||||
|
assert!(
|
||||||
|
graph.get_relationship(&npc_a, &player).is_none(),
|
||||||
|
"A should not receive back-propagation from B (cycle prevention)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_delta_propagates_as_negative() {
|
||||||
|
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
|
||||||
|
// with the same sign.
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let npc_b = StableId(2);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||||
|
npc_a,
|
||||||
|
npc_b,
|
||||||
|
make_edge(RelationshipKind::Friend, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: -2, // confrontation
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// B should trust player LESS after A was confronted
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
let edge = graph.get_relationship(&npc_b, &player)
|
||||||
|
.expect("B should have edge to player");
|
||||||
|
assert!(
|
||||||
|
edge.trust < 0,
|
||||||
|
"Negative propagation should reduce B's trust in player"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_relationships_means_no_propagation() {
|
||||||
|
// If the first-order NPC has no relationships, the queue is drained
|
||||||
|
// but nothing propagates.
|
||||||
|
let mut world = setup_propagation_world();
|
||||||
|
|
||||||
|
let npc_a = StableId(1);
|
||||||
|
let player = StableId(99);
|
||||||
|
|
||||||
|
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||||
|
npc: npc_a,
|
||||||
|
player,
|
||||||
|
delta: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(propagate_social_actions);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let graph = world.resource::<RelationshipGraph>();
|
||||||
|
assert!(graph.is_empty(), "No relationships → no propagation edges created");
|
||||||
|
|
||||||
|
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||||
|
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_trust_populates_propagation_queue() {
|
||||||
|
// Integration: update_trust should push to PropagationQueue for downstream #249.
|
||||||
|
let mut world = setup_trust_world();
|
||||||
|
|
||||||
|
let npc = world.spawn_empty().id();
|
||||||
|
let player = world.spawn_empty().id();
|
||||||
|
world.resource_mut::<EntityRegistry>().register(npc);
|
||||||
|
world.resource_mut::<EntityRegistry>().register(player);
|
||||||
|
|
||||||
|
world
|
||||||
|
.resource_mut::<TrustEventQueue>()
|
||||||
|
.push(TrustEvent::TalkCompleted { npc, player });
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(update_trust);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
// PropagationQueue should have 1 event (for downstream propagation)
|
||||||
|
let prop_events = world.resource_mut::<PropagationQueue>().drain();
|
||||||
|
assert_eq!(prop_events.len(), 1);
|
||||||
|
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregistered_entity_event_is_skipped() {
|
fn unregistered_entity_event_is_skipped() {
|
||||||
let mut world = setup_trust_world();
|
let mut world = setup_trust_world();
|
||||||
|
|||||||
@@ -0,0 +1,826 @@
|
|||||||
|
//! NPC vision system (#115, D-011).
|
||||||
|
//!
|
||||||
|
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
|
||||||
|
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
|
||||||
|
//! positions and zone inferences ("saw you enter building → knows you're
|
||||||
|
//! inside").
|
||||||
|
//!
|
||||||
|
//! ## System ordering
|
||||||
|
//!
|
||||||
|
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
|
||||||
|
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
|
||||||
|
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
|
||||||
|
//!
|
||||||
|
//! ## Performance
|
||||||
|
//!
|
||||||
|
//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within
|
||||||
|
//! D-026 Active tier budget by architecture review.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
use crate::knowledge::{
|
||||||
|
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
|
||||||
|
};
|
||||||
|
use crate::npc::Npc;
|
||||||
|
use crate::perception::shadowcast::compute_fov;
|
||||||
|
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||||
|
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||||
|
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||||||
|
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
|
||||||
|
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
|
||||||
|
pub const NPC_VISION_RANGE: i32 = 20;
|
||||||
|
|
||||||
|
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
|
||||||
|
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Current field-of-view results for an NPC.
|
||||||
|
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
|
||||||
|
#[derive(Component, Debug, Clone, Default)]
|
||||||
|
pub struct NpcVisionState {
|
||||||
|
/// StableIds of entities currently in this NPC's LOS.
|
||||||
|
pub visible_entities: BTreeSet<StableId>,
|
||||||
|
/// Whether the player character is currently visible.
|
||||||
|
pub player_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent memory of entities this NPC has seen.
|
||||||
|
/// Survives after entities leave LOS (D-011: "saw you enter building →
|
||||||
|
/// knows you're inside").
|
||||||
|
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct NpcMemory {
|
||||||
|
/// Last known position + tick for entities this NPC has seen.
|
||||||
|
/// Key: StableId of the observed entity.
|
||||||
|
pub last_known: BTreeMap<StableId, LastKnownEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record of last-known position for a single entity.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LastKnownEntry {
|
||||||
|
/// Position where entity was last seen.
|
||||||
|
pub position: TilePosition,
|
||||||
|
/// Tick when the entity was last observed.
|
||||||
|
pub observed_tick: u64,
|
||||||
|
/// Zone inference: if entity was last seen before leaving LOS,
|
||||||
|
/// the NPC infers they are still nearby.
|
||||||
|
pub zone_inference: Option<ZoneInference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inference that an entity is still near a position based on last observation.
|
||||||
|
/// Degrades after `degrades_at_tick`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneInference {
|
||||||
|
/// The position where the entity was last seen.
|
||||||
|
pub last_seen_position: TilePosition,
|
||||||
|
/// Tick when the entity was seen at this position.
|
||||||
|
pub observed_tick: u64,
|
||||||
|
/// Tick at which this inference degrades (NPC stops assuming entity is here).
|
||||||
|
pub degrades_at_tick: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compute NPC field-of-view for all Active-tier NPCs.
|
||||||
|
///
|
||||||
|
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
|
||||||
|
/// apply vision cone if the NPC has a `Facing` component, then check which entities
|
||||||
|
/// from the spatial index are at visible tiles.
|
||||||
|
pub fn compute_npc_vision(
|
||||||
|
walkability: Option<Res<WalkabilityMap>>,
|
||||||
|
registry: Res<EntityRegistry>,
|
||||||
|
spatial_index: Res<NaiveSpatialIndex>,
|
||||||
|
player_query: Query<Entity, With<PlayerCharacter>>,
|
||||||
|
mut npc_query: Query<
|
||||||
|
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
|
||||||
|
(With<Npc>, With<ActiveSim>),
|
||||||
|
>,
|
||||||
|
entity_positions: Query<&TilePosition>,
|
||||||
|
) {
|
||||||
|
let Some(walkability) = walkability else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let player_entity = player_query.iter().next();
|
||||||
|
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
|
||||||
|
let config = VisionConeConfig::default();
|
||||||
|
|
||||||
|
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
|
||||||
|
let z = npc_pos.z;
|
||||||
|
|
||||||
|
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
|
||||||
|
let fov = compute_fov(
|
||||||
|
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||||
|
npc_pos.x,
|
||||||
|
npc_pos.y,
|
||||||
|
NPC_VISION_RANGE,
|
||||||
|
z,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect visible tile positions — apply vision cone if NPC has facing
|
||||||
|
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
|
||||||
|
let cone_tiles =
|
||||||
|
apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
|
||||||
|
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
|
||||||
|
} else {
|
||||||
|
// No facing → omnidirectional vision (full FOV)
|
||||||
|
fov.visible_tiles().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find entities at visible positions via spatial index
|
||||||
|
let mut new_visible = BTreeSet::new();
|
||||||
|
let mut player_vis = false;
|
||||||
|
|
||||||
|
// Single-pass query: all entities within vision range including own tile
|
||||||
|
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
|
||||||
|
|
||||||
|
for entity in candidates {
|
||||||
|
if entity == npc_entity {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(entity_pos) = entity_positions.get(entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if entity_pos.z != z {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(stable_id) = registry.to_stable(entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
new_visible.insert(stable_id);
|
||||||
|
if Some(stable_id) == player_stable_id {
|
||||||
|
player_vis = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vision_state.visible_entities = new_visible;
|
||||||
|
vision_state.player_visible = player_vis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit knowledge events when NPCs gain or lose sight of the player.
|
||||||
|
///
|
||||||
|
/// Mirrors the player's `emit_observation_events` pattern but scoped to
|
||||||
|
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
|
||||||
|
/// for direct query by downstream systems (#244 awareness) without flooding
|
||||||
|
/// the knowledge event queue.
|
||||||
|
///
|
||||||
|
/// Also updates `NpcMemory` with last-known positions and zone inferences.
|
||||||
|
pub fn emit_npc_vision_events(
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
registry: Res<EntityRegistry>,
|
||||||
|
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||||
|
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||||
|
mut npc_query: Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&NpcVisionState,
|
||||||
|
&mut NpcMemory,
|
||||||
|
Option<&KnowledgeGraph>,
|
||||||
|
),
|
||||||
|
(With<Npc>, With<ActiveSim>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let Ok((player_entity, player_pos)) = player_query.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
|
||||||
|
if vision_state.player_visible {
|
||||||
|
// Player is in LOS — update memory and emit DirectObservation
|
||||||
|
memory.last_known.insert(
|
||||||
|
player_sid,
|
||||||
|
LastKnownEntry {
|
||||||
|
position: *player_pos,
|
||||||
|
observed_tick: time.tick,
|
||||||
|
zone_inference: None, // Active observation clears inference
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
event_queue.push(KnowledgeEvent {
|
||||||
|
observer: npc_entity,
|
||||||
|
tick: time.tick,
|
||||||
|
event_type: KnowledgeEventType::DirectObservation {
|
||||||
|
target: player_entity,
|
||||||
|
position: *player_pos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Player NOT in LOS — check if they WERE Direct (just left)
|
||||||
|
let was_direct = knowledge_graph
|
||||||
|
.and_then(|kg| {
|
||||||
|
kg.entity_knowledge(&player_sid)
|
||||||
|
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if was_direct {
|
||||||
|
// Player just left this NPC's LOS — emit LeftLOS
|
||||||
|
event_queue.push(KnowledgeEvent {
|
||||||
|
observer: npc_entity,
|
||||||
|
tick: time.tick,
|
||||||
|
event_type: KnowledgeEventType::LeftLOS {
|
||||||
|
target: player_entity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create zone inference — NPC remembers where they last saw the player
|
||||||
|
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
|
||||||
|
entry.zone_inference = Some(ZoneInference {
|
||||||
|
last_seen_position: entry.position,
|
||||||
|
observed_tick: entry.observed_tick,
|
||||||
|
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Degrade stale zone inferences in NPC memory.
|
||||||
|
///
|
||||||
|
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
|
||||||
|
/// inference passes its degradation tick, the inference is removed.
|
||||||
|
pub fn degrade_npc_inferences(
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
||||||
|
) {
|
||||||
|
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut memory in npc_query.iter_mut() {
|
||||||
|
for entry in memory.last_known.values_mut() {
|
||||||
|
if let Some(ref inference) = entry.zone_inference {
|
||||||
|
if time.tick >= inference.degrades_at_tick {
|
||||||
|
entry.zone_inference = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||||
|
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
|
||||||
|
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::<KnowledgeEventQueue>();
|
||||||
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world.init_resource::<NaiveSpatialIndex>();
|
||||||
|
world.init_resource::<VisibilityGeometry>();
|
||||||
|
world.init_resource::<ActivePerceptionMode>();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pos(x: i32, y: i32) -> TilePosition {
|
||||||
|
TilePosition::new(x, y, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- compute_npc_vision tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_sees_nearby_entity_in_open_field() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
let npc_sid = registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
let target = world.spawn(pos(16, 14)).id();
|
||||||
|
let target_sid = registry.register(target);
|
||||||
|
spatial.update(target, pos(16, 14));
|
||||||
|
|
||||||
|
// Player entity (required for player_stable_id lookup)
|
||||||
|
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
|
||||||
|
registry.register(player);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
vision.visible_entities.contains(&target_sid),
|
||||||
|
"NPC should see nearby entity in open field"
|
||||||
|
);
|
||||||
|
assert!(!vision.player_visible, "player is far away");
|
||||||
|
let _ = npc_sid; // registered for completeness
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_cannot_see_through_wall() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
// Target behind a wall
|
||||||
|
let target = world.spawn(pos(16, 14)).id();
|
||||||
|
let target_sid = registry.register(target);
|
||||||
|
spatial.update(target, pos(16, 14));
|
||||||
|
|
||||||
|
// Wall between NPC and target
|
||||||
|
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||||
|
walkability.set_walkable(&pos(16, 15), false);
|
||||||
|
drop(walkability);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
!vision.visible_entities.contains(&target_sid),
|
||||||
|
"NPC should not see entity behind wall"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_detects_player_visible() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||||
|
registry.register(player);
|
||||||
|
spatial.update(player, pos(16, 14));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(vision.player_visible, "NPC should detect player in LOS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_does_not_see_entity_on_different_z_level() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
// Target at same x/y but different z
|
||||||
|
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
|
||||||
|
let target_sid = registry.register(target);
|
||||||
|
spatial.update(target, TilePosition::new(16, 14, 1));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
!vision.visible_entities.contains(&target_sid),
|
||||||
|
"NPC should not see entity on different z-level"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_does_not_see_entity_beyond_range() {
|
||||||
|
let mut world = setup_world(64, 64);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
// Target beyond NPC_VISION_RANGE (20 tiles)
|
||||||
|
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
|
||||||
|
let target_sid = registry.register(target);
|
||||||
|
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
!vision.visible_entities.contains(&target_sid),
|
||||||
|
"NPC should not see entity beyond vision range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_does_not_see_self() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
let npc_sid = registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
!vision.visible_entities.contains(&npc_sid),
|
||||||
|
"NPC should not include itself in visible entities"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_sees_non_npc_entity_on_same_tile() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
// NPC at (16, 16)
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
// Non-NPC entity on the same tile (e.g. dropped item)
|
||||||
|
let item = world.spawn(pos(16, 16)).id();
|
||||||
|
let item_sid = registry.register(item);
|
||||||
|
spatial.update(item, pos(16, 16));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
vision.visible_entities.contains(&item_sid),
|
||||||
|
"NPC should see non-NPC entity sharing its tile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_npc_not_processed() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let mut spatial = NaiveSpatialIndex::new();
|
||||||
|
|
||||||
|
// Background NPC — should NOT have its vision computed
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
crate::simulation::tier::BackgroundSim,
|
||||||
|
pos(16, 16),
|
||||||
|
NpcVisionState::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
spatial.update(npc, pos(16, 16));
|
||||||
|
|
||||||
|
let target = world.spawn(pos(16, 14)).id();
|
||||||
|
let target_sid = registry.register(target);
|
||||||
|
spatial.update(target, pos(16, 14));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(spatial);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(compute_npc_vision);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
vision.visible_entities.is_empty(),
|
||||||
|
"Background NPC should not have vision computed"
|
||||||
|
);
|
||||||
|
let _ = target_sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- emit_npc_vision_events tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_seeing_player_emits_direct_observation() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
|
||||||
|
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
let mut vision = NpcVisionState::default();
|
||||||
|
vision.visible_entities.insert(player_sid);
|
||||||
|
vision.player_visible = true;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
pos(16, 16),
|
||||||
|
vision,
|
||||||
|
NpcMemory::default(),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(emit_npc_vision_events);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let queue = world.resource::<KnowledgeEventQueue>();
|
||||||
|
assert!(!queue.is_empty(), "should emit DirectObservation for player");
|
||||||
|
|
||||||
|
let event = &queue.events[0];
|
||||||
|
assert_eq!(event.observer, npc);
|
||||||
|
assert!(matches!(
|
||||||
|
event.event_type,
|
||||||
|
KnowledgeEventType::DirectObservation { target, .. } if target == player
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_losing_player_emits_left_los() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
|
||||||
|
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
// NPC that had Direct knowledge of player (player was just visible)
|
||||||
|
let mut kg = KnowledgeGraph::new();
|
||||||
|
kg.observe_entity(player_sid, pos(16, 14), 50);
|
||||||
|
|
||||||
|
// Memory with last known position
|
||||||
|
let mut memory = NpcMemory::default();
|
||||||
|
memory.last_known.insert(
|
||||||
|
player_sid,
|
||||||
|
LastKnownEntry {
|
||||||
|
position: pos(16, 14),
|
||||||
|
observed_tick: 50,
|
||||||
|
zone_inference: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vision state: player NOT visible now
|
||||||
|
let vision = NpcVisionState::default();
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
|
||||||
|
let mut time = SimulationTime::default();
|
||||||
|
time.tick = 60;
|
||||||
|
world.insert_resource(time);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(emit_npc_vision_events);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let queue = world.resource::<KnowledgeEventQueue>();
|
||||||
|
let has_left_los = queue.events.iter().any(|e| {
|
||||||
|
matches!(
|
||||||
|
e.event_type,
|
||||||
|
KnowledgeEventType::LeftLOS { target } if target == player
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS");
|
||||||
|
|
||||||
|
// Check zone inference was created
|
||||||
|
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
|
||||||
|
let entry = npc_memory.last_known.get(&player_sid).unwrap();
|
||||||
|
assert!(
|
||||||
|
entry.zone_inference.is_some(),
|
||||||
|
"zone inference should be created when player leaves LOS"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
|
||||||
|
60 + INFERENCE_DEGRADE_TICKS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_updates_memory_on_player_observation() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
|
||||||
|
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
let mut vision = NpcVisionState::default();
|
||||||
|
vision.visible_entities.insert(player_sid);
|
||||||
|
vision.player_visible = true;
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
pos(16, 16),
|
||||||
|
vision,
|
||||||
|
NpcMemory::default(),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
registry.register(npc);
|
||||||
|
|
||||||
|
let mut time = SimulationTime::default();
|
||||||
|
time.tick = 100;
|
||||||
|
world.insert_resource(time);
|
||||||
|
world.insert_resource(registry);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(emit_npc_vision_events);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let memory = world.get::<NpcMemory>(npc).unwrap();
|
||||||
|
let entry = memory.last_known.get(&player_sid).unwrap();
|
||||||
|
assert_eq!(entry.position, pos(10, 10));
|
||||||
|
assert_eq!(entry.observed_tick, 100);
|
||||||
|
assert!(entry.zone_inference.is_none(), "active observation = no inference");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- degrade_npc_inferences tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inference_degrades_after_threshold() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
|
||||||
|
let player_sid = StableId(1);
|
||||||
|
let mut memory = NpcMemory::default();
|
||||||
|
memory.last_known.insert(
|
||||||
|
player_sid,
|
||||||
|
LastKnownEntry {
|
||||||
|
position: pos(10, 10),
|
||||||
|
observed_tick: 50,
|
||||||
|
zone_inference: Some(ZoneInference {
|
||||||
|
last_seen_position: pos(10, 10),
|
||||||
|
observed_tick: 50,
|
||||||
|
degrades_at_tick: 100,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||||
|
|
||||||
|
// Tick 90 (before degradation, on minute boundary)
|
||||||
|
let mut time = SimulationTime::default();
|
||||||
|
time.tick = 90;
|
||||||
|
world.insert_resource(time);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(degrade_npc_inferences);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||||
|
"inference should survive before degradation tick"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tick 100 (degradation tick, on minute boundary)
|
||||||
|
let mut time = SimulationTime::default();
|
||||||
|
time.tick = 100;
|
||||||
|
world.insert_resource(time);
|
||||||
|
|
||||||
|
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule2.add_systems(degrade_npc_inferences);
|
||||||
|
schedule2.run(&mut world);
|
||||||
|
|
||||||
|
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
mem.last_known[&player_sid].zone_inference.is_none(),
|
||||||
|
"inference should be removed at degradation tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inference_degradation_skips_non_minute_ticks() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
|
||||||
|
let player_sid = StableId(1);
|
||||||
|
let mut memory = NpcMemory::default();
|
||||||
|
memory.last_known.insert(
|
||||||
|
player_sid,
|
||||||
|
LastKnownEntry {
|
||||||
|
position: pos(10, 10),
|
||||||
|
observed_tick: 50,
|
||||||
|
zone_inference: Some(ZoneInference {
|
||||||
|
last_seen_position: pos(10, 10),
|
||||||
|
observed_tick: 50,
|
||||||
|
degrades_at_tick: 55, // already past
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||||
|
|
||||||
|
// Tick 57 — past degradation but NOT a minute boundary
|
||||||
|
let mut time = SimulationTime::default();
|
||||||
|
time.tick = 57;
|
||||||
|
world.insert_resource(time);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(degrade_npc_inferences);
|
||||||
|
schedule.run(&mut world);
|
||||||
|
|
||||||
|
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||||
|
"degradation should not run on non-minute ticks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_events_when_no_player_entity() {
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
let registry = EntityRegistry::new(0);
|
||||||
|
|
||||||
|
let vision = NpcVisionState::default();
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
pos(16, 16),
|
||||||
|
vision,
|
||||||
|
NpcMemory::default(),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(emit_npc_vision_events);
|
||||||
|
schedule.run(&mut world); // should not panic
|
||||||
|
|
||||||
|
let queue = world.resource::<KnowledgeEventQueue>();
|
||||||
|
assert!(queue.is_empty(), "no events when no player entity exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
|
|||||||
use crate::simulation::contraband::ScanEventBuffer;
|
use crate::simulation::contraband::ScanEventBuffer;
|
||||||
use crate::simulation::conversation::ConversationEventBuffer;
|
use crate::simulation::conversation::ConversationEventBuffer;
|
||||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||||
|
use crate::simulation::examine::ExamineResultBuffer;
|
||||||
use crate::simulation::follow::FollowTarget;
|
use crate::simulation::follow::FollowTarget;
|
||||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||||
@@ -83,6 +84,7 @@ pub fn compute_observer_snapshot(
|
|||||||
Option<&mut ScanEventBuffer>,
|
Option<&mut ScanEventBuffer>,
|
||||||
Option<&mut ConversationEventBuffer>,
|
Option<&mut ConversationEventBuffer>,
|
||||||
Option<&FollowTarget>,
|
Option<&FollowTarget>,
|
||||||
|
Option<&mut ExamineResultBuffer>,
|
||||||
),
|
),
|
||||||
With<PlayerCharacter>,
|
With<PlayerCharacter>,
|
||||||
>,
|
>,
|
||||||
@@ -98,6 +100,7 @@ pub fn compute_observer_snapshot(
|
|||||||
poi_query: Query<&PointOfInterest>,
|
poi_query: Query<&PointOfInterest>,
|
||||||
mut buffer: ResMut<SnapshotBuffer>,
|
mut buffer: ResMut<SnapshotBuffer>,
|
||||||
sim_rng: Option<Res<SimRng>>,
|
sim_rng: Option<Res<SimRng>>,
|
||||||
|
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
|
||||||
) {
|
) {
|
||||||
let Ok((
|
let Ok((
|
||||||
observer_entity,
|
observer_entity,
|
||||||
@@ -114,6 +117,7 @@ pub fn compute_observer_snapshot(
|
|||||||
mut scan_event_buffer_opt,
|
mut scan_event_buffer_opt,
|
||||||
mut conversation_buffer_opt,
|
mut conversation_buffer_opt,
|
||||||
follow_target_opt,
|
follow_target_opt,
|
||||||
|
mut examine_result_buffer_opt,
|
||||||
)) = observer_query.single_mut()
|
)) = observer_query.single_mut()
|
||||||
else {
|
else {
|
||||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||||
@@ -199,6 +203,13 @@ pub fn compute_observer_snapshot(
|
|||||||
|
|
||||||
let current_monologue = monologue_buffer.take();
|
let current_monologue = monologue_buffer.take();
|
||||||
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
||||||
|
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take()).map(
|
||||||
|
|evt| crate::bridge::types::ExamineResultWire {
|
||||||
|
entity_id: evt.target_entity_id,
|
||||||
|
text: evt.text,
|
||||||
|
confidence: crate::knowledge::types::KnowledgeConfidence::KnowsDetails,
|
||||||
|
},
|
||||||
|
);
|
||||||
let scan_events = scan_event_buffer_opt
|
let scan_events = scan_event_buffer_opt
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|buf| buf.take())
|
.map(|buf| buf.take())
|
||||||
@@ -373,6 +384,9 @@ pub fn compute_observer_snapshot(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Consume pending save/load result for this tick (#553).
|
||||||
|
let save_result = buffer.pending_save_result.take();
|
||||||
|
|
||||||
buffer.snapshot = Some(ObserverSnapshot {
|
buffer.snapshot = Some(ObserverSnapshot {
|
||||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||||
tick: time.tick,
|
tick: time.tick,
|
||||||
@@ -391,11 +405,15 @@ pub fn compute_observer_snapshot(
|
|||||||
conversation_events,
|
conversation_events,
|
||||||
conversation_ended,
|
conversation_ended,
|
||||||
follow_state,
|
follow_state,
|
||||||
|
character_pressure: pressure_query.iter().next().map(|p| {
|
||||||
|
crate::simulation::pressure::CharacterPressureWire::from(p)
|
||||||
|
}),
|
||||||
sound_events,
|
sound_events,
|
||||||
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
||||||
poi_list,
|
poi_list,
|
||||||
examine_result: None, // Populated by examine system when #242 lands
|
examine_result,
|
||||||
player_knowledge,
|
player_knowledge,
|
||||||
|
save_result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #337 — Tell state → snapshot integration (D-024 tell system)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: run derive_tell_state + two-stage observer pipeline together.
|
||||||
|
fn run_tell_plus_observer_pipeline(world: &mut World) {
|
||||||
|
use crate::npc::tell_state::derive_tell_state;
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems((
|
||||||
|
derive_tell_state,
|
||||||
|
compute_visibility_geometry.after(derive_tell_state),
|
||||||
|
compute_observer_snapshot
|
||||||
|
.after(compute_visibility_geometry)
|
||||||
|
.after(derive_tell_state),
|
||||||
|
));
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
|
||||||
|
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
|
||||||
|
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
|
||||||
|
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
|
||||||
|
// → compute_observer_snapshot → VisibleEntity.tell_state.
|
||||||
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
|
use crate::npc::tell_state::{DerivedTellState, TellCategory};
|
||||||
|
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
TilePosition::new(16, 16, 0),
|
||||||
|
Facing(FacingDirection::North),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
NearbyInteractionBuffer::default(),
|
||||||
|
MonologueBuffer::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
|
||||||
|
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
TilePosition::new(16, 14, 0),
|
||||||
|
Secret {
|
||||||
|
description: "criminal record".into(),
|
||||||
|
severity: SecretSeverity::Major,
|
||||||
|
known_by: vec![],
|
||||||
|
},
|
||||||
|
ToleranceThreshold { current_stress: 60, threshold: 100 },
|
||||||
|
Contentment { level: 0 },
|
||||||
|
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||||
|
DerivedTellState::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
run_tell_plus_observer_pipeline(&mut world);
|
||||||
|
|
||||||
|
let buffer = world.resource::<SnapshotBuffer>();
|
||||||
|
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||||
|
|
||||||
|
let npc = snapshot
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||||
|
.expect("NPC should be visible in snapshot");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
npc.tell_state,
|
||||||
|
Some(TellCategory::Nervous),
|
||||||
|
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tell_state_none_for_neutral_npc_in_snapshot() {
|
||||||
|
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
|
||||||
|
// Verifies the pipeline correctly omits tell when no conditions are met.
|
||||||
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
|
use crate::npc::tell_state::DerivedTellState;
|
||||||
|
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
TilePosition::new(16, 16, 0),
|
||||||
|
Facing(FacingDirection::North),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
NearbyInteractionBuffer::default(),
|
||||||
|
MonologueBuffer::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
TilePosition::new(16, 14, 0),
|
||||||
|
Secret {
|
||||||
|
description: "minor embarrassment".into(),
|
||||||
|
severity: SecretSeverity::Minor,
|
||||||
|
known_by: vec![],
|
||||||
|
},
|
||||||
|
ToleranceThreshold { current_stress: 10, threshold: 100 },
|
||||||
|
Contentment { level: 0 },
|
||||||
|
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||||
|
DerivedTellState::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
run_tell_plus_observer_pipeline(&mut world);
|
||||||
|
|
||||||
|
let buffer = world.resource::<SnapshotBuffer>();
|
||||||
|
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||||
|
|
||||||
|
let npc = snapshot
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||||
|
.expect("NPC should be visible in snapshot");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
npc.tell_state,
|
||||||
|
None,
|
||||||
|
"neutral NPC should have no tell state in snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tell_state_none_when_npc_has_no_derived_tell_component() {
|
||||||
|
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
|
||||||
|
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
|
||||||
|
|
||||||
|
let mut world = setup_world(32, 32);
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
TilePosition::new(16, 16, 0),
|
||||||
|
Facing(FacingDirection::North),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
NearbyInteractionBuffer::default(),
|
||||||
|
MonologueBuffer::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// NPC with no DerivedTellState component at all
|
||||||
|
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||||
|
|
||||||
|
run_observer_pipeline(&mut world);
|
||||||
|
|
||||||
|
let buffer = world.resource::<SnapshotBuffer>();
|
||||||
|
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||||
|
|
||||||
|
let npc = snapshot
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||||
|
.expect("NPC should be visible in snapshot");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
npc.tell_state,
|
||||||
|
None,
|
||||||
|
"NPC without DerivedTellState component should have tell_state = None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! Examine interaction system (#242).
|
||||||
|
//!
|
||||||
|
//! Handles the Examine verb: player examines an NPC or object at close range,
|
||||||
|
//! generating character-filtered observation text and a DirectObservation
|
||||||
|
//! KnowledgeGraph entry.
|
||||||
|
//!
|
||||||
|
//! Pipeline:
|
||||||
|
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
|
||||||
|
//! → process_player_input inserts ExamineRequest on player
|
||||||
|
//! → process_examine_interaction reads request, generates text, pushes KG event
|
||||||
|
//! → ExamineResultBuffer consumed by compute_observer_snapshot
|
||||||
|
//! → ObserverSnapshot.examine_result delivered to client
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::bridge::types::CharacterArchetype;
|
||||||
|
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||||
|
use crate::knowledge::EntityRegistry;
|
||||||
|
use crate::npc::mood::{MoodState, NpcMood};
|
||||||
|
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
||||||
|
use crate::simulation::interaction::CLOSE_RANGE;
|
||||||
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker: player requested Examine interaction with a target entity this tick.
|
||||||
|
///
|
||||||
|
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
|
||||||
|
/// "Examine Object", or "ExamineObject". Consumed and removed by
|
||||||
|
/// process_examine_interaction each tick.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ExamineRequest {
|
||||||
|
pub target: Entity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Character-filtered examination result for snapshot delivery.
|
||||||
|
///
|
||||||
|
/// Content differs per CharacterArchetype:
|
||||||
|
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||||
|
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExamineResultEvent {
|
||||||
|
/// Character-filtered observation text for client display.
|
||||||
|
pub text: String,
|
||||||
|
/// Wire-format entity identifier of the examined entity.
|
||||||
|
pub target_entity_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer holding the examine result for snapshot inclusion.
|
||||||
|
///
|
||||||
|
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||||
|
/// Attach to the player entity alongside other buffer components.
|
||||||
|
#[derive(Component, Debug, Default)]
|
||||||
|
pub struct ExamineResultBuffer {
|
||||||
|
pub(crate) result: Option<ExamineResultEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExamineResultBuffer {
|
||||||
|
/// Drain and return the examine result, leaving the buffer empty.
|
||||||
|
pub fn take(&mut self) -> Option<ExamineResultEvent> {
|
||||||
|
self.result.take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text generation (deterministic, integer-only — D-010)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
|
||||||
|
/// Uses integer multiplication to avoid division by zero.
|
||||||
|
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
|
||||||
|
if threshold.threshold <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
|
||||||
|
as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map NpcMood to a terse descriptor shared by both archetypes.
|
||||||
|
fn mood_word(mood: NpcMood) -> &'static str {
|
||||||
|
match mood {
|
||||||
|
NpcMood::Neutral => "neutral",
|
||||||
|
NpcMood::Anxious => "anxious",
|
||||||
|
NpcMood::Frustrated => "frustrated",
|
||||||
|
NpcMood::Content => "at ease",
|
||||||
|
NpcMood::Suspicious => "watchful",
|
||||||
|
NpcMood::Warm => "open",
|
||||||
|
NpcMood::Hostile => "hostile",
|
||||||
|
NpcMood::Focused => "focused",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
|
||||||
|
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate character-filtered examination text from NPC component state.
|
||||||
|
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||||
|
pub fn generate_examine_text(
|
||||||
|
mood: NpcMood,
|
||||||
|
ratio: u8,
|
||||||
|
archetype: CharacterArchetype,
|
||||||
|
traits_opt: Option<&PersonalityTraits>,
|
||||||
|
) -> String {
|
||||||
|
let stress_label = match ratio {
|
||||||
|
0..=30 => "relaxed",
|
||||||
|
31..=60 => "tense",
|
||||||
|
61..=85 => "stressed",
|
||||||
|
_ => "near breaking point",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mood_label = mood_word(mood);
|
||||||
|
|
||||||
|
match archetype {
|
||||||
|
CharacterArchetype::Smuggler => {
|
||||||
|
// Physical threat read + cargo opportunity window
|
||||||
|
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||||
|
"Threat posture. Don't push it."
|
||||||
|
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||||
|
"Confident bearing. Will push back if cornered."
|
||||||
|
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||||
|
"Nervous type. Predictable under pressure."
|
||||||
|
} else {
|
||||||
|
"No obvious threat read."
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = if ratio > 60 {
|
||||||
|
"Too distracted to track cargo movement."
|
||||||
|
} else if matches!(mood, NpcMood::Focused) {
|
||||||
|
"Paying close attention to this section."
|
||||||
|
} else {
|
||||||
|
"Standard patrol pattern. Window is there."
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterArchetype::Detective => {
|
||||||
|
// Procedural tells + behavioral read
|
||||||
|
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||||
|
"Controlled affect — practiced concealment."
|
||||||
|
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||||
|
"Involuntary stress markers present."
|
||||||
|
} else if matches!(mood, NpcMood::Suspicious) {
|
||||||
|
"Scanning. Aware of being observed."
|
||||||
|
} else {
|
||||||
|
"Baseline presentation."
|
||||||
|
};
|
||||||
|
|
||||||
|
let read = if ratio > 60 {
|
||||||
|
"Under pressure — potential liability or asset."
|
||||||
|
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||||
|
"Comfortable. Less guarded than usual."
|
||||||
|
} else {
|
||||||
|
"Routine behavior pattern."
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Process examine interaction: generate character-filtered observation text,
|
||||||
|
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
||||||
|
///
|
||||||
|
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn process_examine_interaction(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
registry: Res<EntityRegistry>,
|
||||||
|
mut kg_events: ResMut<KnowledgeEventQueue>,
|
||||||
|
mut player_query: Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&TilePosition,
|
||||||
|
&ExamineRequest,
|
||||||
|
Option<&CharacterArchetype>,
|
||||||
|
&mut ExamineResultBuffer,
|
||||||
|
),
|
||||||
|
With<PlayerCharacter>,
|
||||||
|
>,
|
||||||
|
npc_query: Query<(
|
||||||
|
&TilePosition,
|
||||||
|
Option<&MoodState>,
|
||||||
|
Option<&ToleranceThreshold>,
|
||||||
|
Option<&PersonalityTraits>,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
|
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||||
|
player_query.single_mut()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = examine_req.target;
|
||||||
|
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||||
|
|
||||||
|
// Range check — examine requires close range (same as Talk/Confront)
|
||||||
|
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
|
||||||
|
tracing::warn!(?target, "process_examine_interaction: target not in query");
|
||||||
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||||
|
if distance > CLOSE_RANGE {
|
||||||
|
tracing::info!(
|
||||||
|
distance,
|
||||||
|
"Examine: target out of range (max {})",
|
||||||
|
CLOSE_RANGE
|
||||||
|
);
|
||||||
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||||
|
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||||
|
|
||||||
|
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||||
|
|
||||||
|
// Push DirectObservation to KnowledgeEventQueue
|
||||||
|
kg_events.push(KnowledgeEvent {
|
||||||
|
observer: player_entity,
|
||||||
|
tick: time.tick,
|
||||||
|
event_type: KnowledgeEventType::DirectObservation {
|
||||||
|
target,
|
||||||
|
position: *target_pos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve target wire ID for snapshot
|
||||||
|
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||||
|
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
|
||||||
|
target.to_bits()
|
||||||
|
});
|
||||||
|
|
||||||
|
result_buffer.result = Some(ExamineResultEvent {
|
||||||
|
text,
|
||||||
|
target_entity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
target_entity_id,
|
||||||
|
"Examine: DirectObservation pushed, result written to buffer"
|
||||||
|
);
|
||||||
|
|
||||||
|
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::npc::PersonalityTrait;
|
||||||
|
|
||||||
|
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
|
||||||
|
PersonalityTraits { traits: t.to_vec() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smuggler_hostile_npc_gives_threat_read() {
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Hostile,
|
||||||
|
20,
|
||||||
|
CharacterArchetype::Smuggler,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smuggler_focused_npc_notes_attention() {
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Focused,
|
||||||
|
30,
|
||||||
|
CharacterArchetype::Smuggler,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(text.contains("close attention"), "expected attention note, got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smuggler_high_stress_identifies_distraction() {
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Anxious,
|
||||||
|
80,
|
||||||
|
CharacterArchetype::Smuggler,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detective_deceptive_npc_notes_concealment() {
|
||||||
|
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Neutral,
|
||||||
|
20,
|
||||||
|
CharacterArchetype::Detective,
|
||||||
|
Some(&t),
|
||||||
|
);
|
||||||
|
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detective_anxious_npc_notes_stress_markers() {
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Anxious,
|
||||||
|
50,
|
||||||
|
CharacterArchetype::Detective,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("stress markers"),
|
||||||
|
"expected stress markers, got: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detective_content_npc_notes_low_guard() {
|
||||||
|
let text = generate_examine_text(
|
||||||
|
NpcMood::Content,
|
||||||
|
10,
|
||||||
|
CharacterArchetype::Detective,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("Less guarded"),
|
||||||
|
"expected low guard note, got: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stress_ratio_zero_when_threshold_zero() {
|
||||||
|
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
|
||||||
|
assert_eq!(stress_ratio(&t), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stress_ratio_clamped_at_100() {
|
||||||
|
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
|
||||||
|
assert_eq!(stress_ratio(&t), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stress_ratio_negative_stress_is_zero() {
|
||||||
|
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
|
||||||
|
assert_eq!(stress_ratio(&t), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn examine_result_buffer_take_drains() {
|
||||||
|
let mut buf = ExamineResultBuffer::default();
|
||||||
|
assert!(buf.take().is_none());
|
||||||
|
buf.result = Some(ExamineResultEvent {
|
||||||
|
text: "Test".into(),
|
||||||
|
target_entity_id: 42,
|
||||||
|
});
|
||||||
|
assert!(buf.take().is_some());
|
||||||
|
assert!(buf.take().is_none()); // idempotent drain
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use crate::simulation::inventory::{
|
|||||||
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
|
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
|
||||||
};
|
};
|
||||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||||
|
use crate::simulation::save_io::{SaveLoadCommand, SaveLoadPending};
|
||||||
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
||||||
use crate::simulation::time::{SimulationTime, TickRate};
|
use crate::simulation::time::{SimulationTime, TickRate};
|
||||||
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
||||||
@@ -74,7 +75,8 @@ impl InputQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||||
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
|
/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424),
|
||||||
|
/// and save/load commands (#553).
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
pub fn process_player_input(
|
pub fn process_player_input(
|
||||||
mut input_queue: ResMut<InputQueue>,
|
mut input_queue: ResMut<InputQueue>,
|
||||||
@@ -94,6 +96,7 @@ pub fn process_player_input(
|
|||||||
all_positions: Query<&TilePosition>,
|
all_positions: Query<&TilePosition>,
|
||||||
reset_triggers: Query<&RoomResetTrigger>,
|
reset_triggers: Query<&RoomResetTrigger>,
|
||||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||||
|
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||||
) {
|
) {
|
||||||
let current_tick = time.tick;
|
let current_tick = time.tick;
|
||||||
let paused = time.paused();
|
let paused = time.paused();
|
||||||
@@ -104,12 +107,15 @@ pub fn process_player_input(
|
|||||||
|
|
||||||
for input in inputs {
|
for input in inputs {
|
||||||
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
||||||
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
|
// SaveGame/LoadGame are also exempted — saving while paused is valid (#553).
|
||||||
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
|
|
||||||
if paused
|
if paused
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
input.action,
|
input.action,
|
||||||
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
|
PlayerAction::Pause
|
||||||
|
| PlayerAction::Unpause
|
||||||
|
| PlayerAction::TeleportToHub
|
||||||
|
| PlayerAction::SaveGame { .. }
|
||||||
|
| PlayerAction::LoadGame { .. }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -221,6 +227,16 @@ pub fn process_player_input(
|
|||||||
current_tick,
|
current_tick,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
|
||||||
|
| Some("ExamineObject") | Some("Observe") => {
|
||||||
|
handle_examine(
|
||||||
|
&mut commands,
|
||||||
|
®istry,
|
||||||
|
&player_query,
|
||||||
|
&all_positions,
|
||||||
|
target_entity_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
Some("Confront") => {
|
Some("Confront") => {
|
||||||
handle_confront(
|
handle_confront(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
@@ -296,6 +312,36 @@ pub fn process_player_input(
|
|||||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||||
}
|
}
|
||||||
|
PlayerAction::SaveGame { ref path } => {
|
||||||
|
if let Some(ref mut sl) = save_load {
|
||||||
|
if sl.pending.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
"SaveGame overwrites already-pending save/load command (dropped)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sl.pending = Some(SaveLoadCommand::Save {
|
||||||
|
path: std::path::PathBuf::from(path),
|
||||||
|
});
|
||||||
|
tracing::info!("SaveGame queued: {:?}", path);
|
||||||
|
} else {
|
||||||
|
tracing::warn!("SaveGame received but SaveLoadPending resource not registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerAction::LoadGame { ref path } => {
|
||||||
|
if let Some(ref mut sl) = save_load {
|
||||||
|
if sl.pending.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
"LoadGame overwrites already-pending save/load command (dropped)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sl.pending = Some(SaveLoadCommand::Load {
|
||||||
|
path: std::path::PathBuf::from(path),
|
||||||
|
});
|
||||||
|
tracing::info!("LoadGame queued: {:?}", path);
|
||||||
|
} else {
|
||||||
|
tracing::warn!("LoadGame received but SaveLoadPending resource not registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +517,65 @@ fn handle_talk(
|
|||||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
|
||||||
|
/// Examine is available at close range (same as Talk). Range check here matches
|
||||||
|
/// the server-side guard in process_examine_interaction.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn handle_examine(
|
||||||
|
commands: &mut Commands,
|
||||||
|
registry: &EntityRegistry,
|
||||||
|
player_query: &Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&TilePosition,
|
||||||
|
Option<&mut crate::simulation::stance::Stance>,
|
||||||
|
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
|
||||||
|
),
|
||||||
|
With<PlayerCharacter>,
|
||||||
|
>,
|
||||||
|
all_positions: &Query<&TilePosition>,
|
||||||
|
target_entity_id: Option<u64>,
|
||||||
|
) {
|
||||||
|
let Some(target_id) = target_entity_id else {
|
||||||
|
tracing::warn!("Examine verb without target_entity_id");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_stable = StableId(target_id);
|
||||||
|
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||||
|
tracing::warn!(target_id, "Examine: target entity not in registry");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side range check: reject Examine if target is beyond close range
|
||||||
|
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||||
|
let distance = player_pos
|
||||||
|
.manhattan_distance(target_pos)
|
||||||
|
.unwrap_or(u32::MAX);
|
||||||
|
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||||
|
tracing::info!(
|
||||||
|
target_id,
|
||||||
|
distance,
|
||||||
|
"Examine: target out of range (max {})",
|
||||||
|
crate::simulation::interaction::CLOSE_RANGE,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands
|
||||||
|
.entity(player_entity)
|
||||||
|
.insert(crate::simulation::examine::ExamineRequest {
|
||||||
|
target: target_entity,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
||||||
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
|||||||
pub mod contraband;
|
pub mod contraband;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod dialogue;
|
pub mod dialogue;
|
||||||
|
pub mod examine;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
@@ -19,7 +20,10 @@ pub mod path_follow;
|
|||||||
pub mod pathfinding;
|
pub mod pathfinding;
|
||||||
pub mod poi;
|
pub mod poi;
|
||||||
pub mod poi_discovery;
|
pub mod poi_discovery;
|
||||||
|
pub mod pressure;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
pub mod save_io;
|
||||||
|
pub mod save_state;
|
||||||
pub mod sound;
|
pub mod sound;
|
||||||
pub mod spatial;
|
pub mod spatial;
|
||||||
pub mod stance;
|
pub mod stance;
|
||||||
@@ -40,6 +44,7 @@ impl Plugin for SimulationPlugin {
|
|||||||
app.init_resource::<time::SimulationTime>()
|
app.init_resource::<time::SimulationTime>()
|
||||||
.insert_resource(rng::SimRng::new(0))
|
.insert_resource(rng::SimRng::new(0))
|
||||||
.init_resource::<input::InputQueue>()
|
.init_resource::<input::InputQueue>()
|
||||||
|
.init_resource::<save_io::SaveLoadPending>()
|
||||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||||
.init_resource::<sound::SoundEventQueue>()
|
.init_resource::<sound::SoundEventQueue>()
|
||||||
.init_resource::<spatial::NaiveSpatialIndex>()
|
.init_resource::<spatial::NaiveSpatialIndex>()
|
||||||
@@ -58,6 +63,12 @@ impl Plugin for SimulationPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
input::process_player_input,
|
input::process_player_input,
|
||||||
|
// execute_save_load is an exclusive system (takes &mut World).
|
||||||
|
// Must run after process_player_input (which queues the command)
|
||||||
|
// and before compute_observer_snapshot (which consumes the result).
|
||||||
|
save_io::execute_save_load
|
||||||
|
.after(input::process_player_input)
|
||||||
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
pathfinding::compute_paths.after(input::process_player_input),
|
pathfinding::compute_paths.after(input::process_player_input),
|
||||||
path_follow::follow_paths.after(pathfinding::compute_paths),
|
path_follow::follow_paths.after(pathfinding::compute_paths),
|
||||||
movement::validate_movement.after(path_follow::follow_paths),
|
movement::validate_movement.after(path_follow::follow_paths),
|
||||||
@@ -78,6 +89,13 @@ impl Plugin for SimulationPlugin {
|
|||||||
poi_discovery::discover_pois
|
poi_discovery::discover_pois
|
||||||
.after(crate::perception::observer::compute_visibility_geometry)
|
.after(crate::perception::observer::compute_visibility_geometry)
|
||||||
.before(crate::perception::observer::compute_observer_snapshot),
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
|
examine::process_examine_interaction
|
||||||
|
.after(input::process_player_input)
|
||||||
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
|
// Character pressure (#248) — reads NPC awareness + relationship graph
|
||||||
|
pressure::update_character_pressure
|
||||||
|
.after(crate::npc::awareness::detect_player_awareness)
|
||||||
|
.before(crate::perception::observer::compute_observer_snapshot),
|
||||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! Character goal/pressure framework (#248).
|
||||||
|
//!
|
||||||
|
//! Defines systemic pressures on the player character that modulate monologue
|
||||||
|
//! salience and observation priority. Not scripted arcs — emergent from
|
||||||
|
//! interaction of existing D-024 axes.
|
||||||
|
//!
|
||||||
|
//! ## Three pressure axes
|
||||||
|
//!
|
||||||
|
//! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness)
|
||||||
|
//! - **Relationship**: rises when NPCs distrust the player (negative trust in
|
||||||
|
//! `RelationshipGraph`)
|
||||||
|
//! - **Institutional**: detective-specific pressure (stub in v0.1)
|
||||||
|
//!
|
||||||
|
//! ## Update frequency
|
||||||
|
//!
|
||||||
|
//! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid
|
||||||
|
//! per-tick overhead of relationship graph scans.
|
||||||
|
//!
|
||||||
|
//! ## Output surface
|
||||||
|
//!
|
||||||
|
//! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD
|
||||||
|
//! - `pressure_mood()` helper for monologue salience weighting — high pressure
|
||||||
|
//! biases toward anxiety-tagged lines (D-035 mood tags)
|
||||||
|
//!
|
||||||
|
//! All arithmetic is integer-only (D-010 determinism).
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::npc::awareness::PlayerAwareness;
|
||||||
|
use crate::npc::Npc;
|
||||||
|
use crate::simulation::movement::PlayerCharacter;
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031).
|
||||||
|
pub const PRESSURE_UPDATE_INTERVAL: u64 = 10;
|
||||||
|
|
||||||
|
/// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure.
|
||||||
|
pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10;
|
||||||
|
|
||||||
|
/// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure.
|
||||||
|
pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15;
|
||||||
|
|
||||||
|
/// Pressure threshold above which monologue mood shifts to "anxious".
|
||||||
|
pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50;
|
||||||
|
|
||||||
|
/// Pressure threshold above which monologue mood shifts to "frustrated".
|
||||||
|
/// Below anxious threshold but above this → frustrated.
|
||||||
|
pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30;
|
||||||
|
|
||||||
|
/// Trust value at or below which a relationship counts as "hostile" for
|
||||||
|
/// relationship pressure.
|
||||||
|
pub const HOSTILE_TRUST_THRESHOLD: i8 = -3;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Systemic pressure on the player character (#248).
|
||||||
|
///
|
||||||
|
/// Attached to the player entity. Updated every game-minute by
|
||||||
|
/// `update_character_pressure`. Feeds into monologue salience weighting
|
||||||
|
/// and ObserverSnapshot HUD data.
|
||||||
|
///
|
||||||
|
/// All values are 0–100, integer for D-010 determinism.
|
||||||
|
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CharacterPressure {
|
||||||
|
/// Exposure pressure: rises when NPCs notice the player watching them.
|
||||||
|
/// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs.
|
||||||
|
pub exposure: i32,
|
||||||
|
/// Institutional pressure: detective-specific systemic pressure.
|
||||||
|
/// Stub in v0.1 — future sprints wire this to detective interaction patterns.
|
||||||
|
pub institutional: i32,
|
||||||
|
/// Relationship pressure: rises when NPCs distrust the player.
|
||||||
|
/// Derived from negative trust edges in the `RelationshipGraph`.
|
||||||
|
pub relationship: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharacterPressure {
|
||||||
|
/// Total pressure as a simple average of all axes (0–100).
|
||||||
|
///
|
||||||
|
/// Uses integer division — remainders are floor-truncated (D-010, no floats).
|
||||||
|
/// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67).
|
||||||
|
pub fn total(&self) -> i32 {
|
||||||
|
// Simple average, clamped. Integer division truncates toward zero (D-010).
|
||||||
|
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dominant mood tag for monologue salience weighting.
|
||||||
|
///
|
||||||
|
/// Returns the D-035 mood tag that should be preferred when selecting
|
||||||
|
/// monologue lines. `None` when pressure is low — baseline monologue
|
||||||
|
/// selection applies.
|
||||||
|
pub fn pressure_mood(&self) -> Option<&'static str> {
|
||||||
|
let total = self.total();
|
||||||
|
if total >= MOOD_ANXIOUS_THRESHOLD {
|
||||||
|
Some("anxious")
|
||||||
|
} else if total >= MOOD_FRUSTRATED_THRESHOLD {
|
||||||
|
Some("frustrated")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire type for ObserverSnapshot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Character pressure data for client HUD display (#248).
|
||||||
|
///
|
||||||
|
/// Included in `ObserverSnapshot` when pressure is non-zero.
|
||||||
|
/// Client renders as a tension/pressure indicator widget.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CharacterPressureWire {
|
||||||
|
/// Exposure pressure (0–100).
|
||||||
|
pub exposure: i32,
|
||||||
|
/// Institutional pressure (0–100).
|
||||||
|
pub institutional: i32,
|
||||||
|
/// Relationship pressure (0–100).
|
||||||
|
pub relationship: i32,
|
||||||
|
/// Total pressure (0–100).
|
||||||
|
pub total: i32,
|
||||||
|
/// Dominant mood tag, if any.
|
||||||
|
pub mood: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CharacterPressure> for CharacterPressureWire {
|
||||||
|
fn from(p: &CharacterPressure) -> Self {
|
||||||
|
Self {
|
||||||
|
exposure: p.exposure,
|
||||||
|
institutional: p.institutional,
|
||||||
|
relationship: p.relationship,
|
||||||
|
total: p.total(),
|
||||||
|
mood: p.pressure_mood().map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Update character pressure from NPC awareness and relationship state.
|
||||||
|
///
|
||||||
|
/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute).
|
||||||
|
///
|
||||||
|
/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by
|
||||||
|
/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100
|
||||||
|
/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the
|
||||||
|
/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE`
|
||||||
|
/// - **Institutional**: stub (0) in v0.1
|
||||||
|
pub fn update_character_pressure(
|
||||||
|
time: Res<SimulationTime>,
|
||||||
|
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
|
||||||
|
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
|
||||||
|
registry: Res<crate::knowledge::EntityRegistry>,
|
||||||
|
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
|
||||||
|
) {
|
||||||
|
// Only run on interval ticks
|
||||||
|
if time.tick % PRESSURE_UPDATE_INTERVAL != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok((player_entity, mut pressure)) = player_query.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Exposure pressure ---
|
||||||
|
let suspicious_count = awareness_query
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.suspicion_level > 0)
|
||||||
|
.count() as i32;
|
||||||
|
pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100);
|
||||||
|
|
||||||
|
// --- Relationship pressure ---
|
||||||
|
let player_stable = registry.to_stable(player_entity);
|
||||||
|
if let Some(player_sid) = player_stable {
|
||||||
|
// O(N) over all relationship edges — called once per game-minute, not every tick.
|
||||||
|
// Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations).
|
||||||
|
let hostile_edges = relationship_graph
|
||||||
|
.who_knows_full_scan(&player_sid)
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD)
|
||||||
|
.count() as i32;
|
||||||
|
pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Institutional pressure ---
|
||||||
|
// Stub: v0.1 has no detective-specific interaction patterns yet.
|
||||||
|
// Future sprints wire this to game-time progression, investigation progress,
|
||||||
|
// and institutional NPC interactions.
|
||||||
|
// pressure.institutional stays at whatever it was (default 0).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::knowledge::EntityRegistry;
|
||||||
|
use crate::npc::awareness::PlayerAwareness;
|
||||||
|
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||||
|
use crate::npc::{Npc, RelationshipKind};
|
||||||
|
use crate::simulation::movement::PlayerCharacter;
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
|
||||||
|
fn setup_world() -> World {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SimulationTime>();
|
||||||
|
world.init_resource::<RelationshipGraph>();
|
||||||
|
world.init_resource::<EntityRegistry>();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_system(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(update_character_pressure);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Update interval gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_non_interval_ticks() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 13; // not on interval
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
PlayerAwareness {
|
||||||
|
suspicion_level: 50,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(pressure.exposure, 0, "should not update on non-interval tick");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runs_on_interval_tick() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10; // on interval
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
PlayerAwareness {
|
||||||
|
suspicion_level: 50,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||||
|
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||||
|
"should update on interval tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Exposure pressure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposure_from_suspicious_npcs() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
// 3 suspicious NPCs
|
||||||
|
for _ in 0..3 {
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
PlayerAwareness {
|
||||||
|
suspicion_level: 10,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 2 non-suspicious NPCs
|
||||||
|
for _ in 0..2 {
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
PlayerAwareness::default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pressure.exposure,
|
||||||
|
3 * EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||||
|
"3 suspicious NPCs × {} per NPC",
|
||||||
|
EXPOSURE_PER_SUSPICIOUS_NPC
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposure_caps_at_100() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
// 20 suspicious NPCs — would be 200, should cap at 100
|
||||||
|
for _ in 0..20 {
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
PlayerAwareness {
|
||||||
|
suspicion_level: 50,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(pressure.exposure, 100, "exposure should cap at 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_suspicious_npcs_zero_exposure() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
world.spawn((Npc, ActiveSim, PlayerAwareness::default()));
|
||||||
|
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(pressure.exposure, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Relationship pressure
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relationship_pressure_from_hostile_edges() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let player = world
|
||||||
|
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||||
|
.id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
// Two NPCs with hostile trust toward the player
|
||||||
|
let npc1 = world.spawn(Npc).id();
|
||||||
|
let npc1_sid = registry.register(npc1);
|
||||||
|
let npc2 = world.spawn(Npc).id();
|
||||||
|
let npc2_sid = registry.register(npc2);
|
||||||
|
|
||||||
|
let mut graph = RelationshipGraph::new();
|
||||||
|
graph.set_relationship(
|
||||||
|
npc1_sid,
|
||||||
|
player_sid,
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Colleague,
|
||||||
|
trust: -5, // hostile
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.set_relationship(
|
||||||
|
npc2_sid,
|
||||||
|
player_sid,
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Colleague,
|
||||||
|
trust: -4, // hostile
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(graph);
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pressure.relationship,
|
||||||
|
2 * RELATIONSHIP_PER_HOSTILE_EDGE,
|
||||||
|
"2 hostile edges × {} per edge",
|
||||||
|
RELATIONSHIP_PER_HOSTILE_EDGE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neutral_trust_no_relationship_pressure() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let player = world
|
||||||
|
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||||
|
.id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
let npc = world.spawn(Npc).id();
|
||||||
|
let npc_sid = registry.register(npc);
|
||||||
|
|
||||||
|
let mut graph = RelationshipGraph::new();
|
||||||
|
graph.set_relationship(
|
||||||
|
npc_sid,
|
||||||
|
player_sid,
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Colleague,
|
||||||
|
trust: 0, // neutral
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(graph);
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(pressure.relationship, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_at_boundary_not_hostile() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
|
||||||
|
let mut registry = EntityRegistry::new(0);
|
||||||
|
let player = world
|
||||||
|
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||||
|
.id();
|
||||||
|
let player_sid = registry.register(player);
|
||||||
|
|
||||||
|
let npc = world.spawn(Npc).id();
|
||||||
|
let npc_sid = registry.register(npc);
|
||||||
|
|
||||||
|
let mut graph = RelationshipGraph::new();
|
||||||
|
graph.set_relationship(
|
||||||
|
npc_sid,
|
||||||
|
player_sid,
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Colleague,
|
||||||
|
trust: -2, // above hostile threshold (-3)
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
world.insert_resource(registry);
|
||||||
|
world.insert_resource(graph);
|
||||||
|
run_system(&mut world);
|
||||||
|
|
||||||
|
let mut q = world.query::<&CharacterPressure>();
|
||||||
|
let pressure = q.single(&world).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pressure.relationship, 0,
|
||||||
|
"trust -2 should not count as hostile (threshold is -3)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Total + mood
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn total_is_average_clamped() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 60,
|
||||||
|
institutional: 30,
|
||||||
|
relationship: 45,
|
||||||
|
};
|
||||||
|
// (60 + 30 + 45) / 3 = 45
|
||||||
|
assert_eq!(p.total(), 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn total_does_not_go_below_zero() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 0,
|
||||||
|
institutional: 0,
|
||||||
|
relationship: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(p.total(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressure_mood_anxious() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 100,
|
||||||
|
institutional: 100,
|
||||||
|
relationship: 100,
|
||||||
|
};
|
||||||
|
// total = 100, >= 50 → anxious
|
||||||
|
assert_eq!(p.pressure_mood(), Some("anxious"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressure_mood_frustrated() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 50,
|
||||||
|
institutional: 50,
|
||||||
|
relationship: 20,
|
||||||
|
};
|
||||||
|
// total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated
|
||||||
|
assert_eq!(p.pressure_mood(), Some("frustrated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressure_mood_none_when_low() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 10,
|
||||||
|
institutional: 0,
|
||||||
|
relationship: 10,
|
||||||
|
};
|
||||||
|
// total = (10+0+10)/3 = 6, < 30 → None
|
||||||
|
assert_eq!(p.pressure_mood(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Wire roundtrip
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_roundtrip() {
|
||||||
|
let p = CharacterPressure {
|
||||||
|
exposure: 30,
|
||||||
|
institutional: 10,
|
||||||
|
relationship: 50,
|
||||||
|
};
|
||||||
|
let wire = CharacterPressureWire::from(&p);
|
||||||
|
let json = serde_json::to_string(&wire).expect("should serialize");
|
||||||
|
let decoded: CharacterPressureWire =
|
||||||
|
serde_json::from_str(&json).expect("should deserialize");
|
||||||
|
assert_eq!(decoded.exposure, 30);
|
||||||
|
assert_eq!(decoded.institutional, 10);
|
||||||
|
assert_eq!(decoded.relationship, 50);
|
||||||
|
assert_eq!(decoded.total, p.total());
|
||||||
|
assert_eq!(decoded.mood, p.pressure_mood().map(String::from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// No player entity — no panic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_player_no_panic() {
|
||||||
|
let mut world = setup_world();
|
||||||
|
world.resource_mut::<SimulationTime>().tick = 10;
|
||||||
|
run_system(&mut world); // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Constant value assertions (#248 spec compliance)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressure_constants_have_expected_values() {
|
||||||
|
assert_eq!(
|
||||||
|
PRESSURE_UPDATE_INTERVAL, 10,
|
||||||
|
"#248: pressure updates every 10 ticks (1 game-minute, D-031)"
|
||||||
|
);
|
||||||
|
assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10);
|
||||||
|
assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15);
|
||||||
|
assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50);
|
||||||
|
assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30);
|
||||||
|
assert_eq!(HOSTILE_TRUST_THRESHOLD, -3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
// Save/load ECS extraction (#553)
|
||||||
|
// Implements D-020 MessagePack format for save files, D-010 determinism.
|
||||||
|
//
|
||||||
|
// Two entry points:
|
||||||
|
// save_to_file: queries ECS, builds SaveStateV1, writes MessagePack to path.
|
||||||
|
// load_from_file: reads path, deserialises SaveStateV1, re-injects ECS state.
|
||||||
|
//
|
||||||
|
// IPC: SaveGame / LoadGame PlayerAction variants queue commands here.
|
||||||
|
// execute_save_load: exclusive system that drains the queue and writes the result
|
||||||
|
// to SnapshotBuffer.pending_save_result for client feedback.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::bridge::types::SaveLoadResultWire;
|
||||||
|
use crate::bridge::types::SnapshotBuffer;
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
|
use crate::knowledge::registry::EntityRegistry;
|
||||||
|
use crate::npc::Npc;
|
||||||
|
use crate::npc::relationships::RelationshipGraph;
|
||||||
|
use crate::simulation::movement::PlayerCharacter;
|
||||||
|
use crate::simulation::rng::SimRng;
|
||||||
|
use crate::simulation::save_state::{
|
||||||
|
deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
use crate::simulation::tier::BackgroundSim;
|
||||||
|
use crate::simulation::time::SimulationTime;
|
||||||
|
|
||||||
|
/// Errors from save/load operations (#553).
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SaveLoadError {
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Serialize(String),
|
||||||
|
#[error("deserialization error: {0}")]
|
||||||
|
Deserialize(String),
|
||||||
|
#[error("format version mismatch: expected {expected}, found {found}")]
|
||||||
|
VersionMismatch { expected: u8, found: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queued save or load command (#553).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SaveLoadCommand {
|
||||||
|
Save { path: PathBuf },
|
||||||
|
Load { path: PathBuf },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending save/load command resource (#553).
|
||||||
|
///
|
||||||
|
/// `process_player_input` queues commands here when it encounters
|
||||||
|
/// `PlayerAction::SaveGame` or `PlayerAction::LoadGame`. The
|
||||||
|
/// `execute_save_load` exclusive system drains this queue each tick.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct SaveLoadPending {
|
||||||
|
/// Pending command (at most one; new commands overwrite pending ones).
|
||||||
|
pub pending: Option<SaveLoadCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553).
|
||||||
|
///
|
||||||
|
/// Queries all NPC entities, the player knowledge graph, global relationship graph,
|
||||||
|
/// simulation time, and RNG seed. Builds `SaveStateV1` and writes to disk.
|
||||||
|
///
|
||||||
|
/// NPC states are sorted by `stable_id` ascending for determinism (D-010).
|
||||||
|
/// NPCs without `StableEntityId` trigger a panic (caller invariant — all live
|
||||||
|
/// NPCs must be registered before save).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `SaveLoadError::Io` on filesystem failure.
|
||||||
|
/// `SaveLoadError::Serialize` on MessagePack encoding failure.
|
||||||
|
pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> {
|
||||||
|
// Simulation clock
|
||||||
|
let (tick, tick_rate) = {
|
||||||
|
let t = world.resource::<SimulationTime>();
|
||||||
|
(t.tick, t.tick_rate)
|
||||||
|
};
|
||||||
|
|
||||||
|
// RNG seed for deterministic replay (D-010)
|
||||||
|
let seed = world.resource::<SimRng>().seed();
|
||||||
|
|
||||||
|
// Player knowledge graph — the observer's epistemics at save time
|
||||||
|
let player_knowledge = {
|
||||||
|
let mut q = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
|
||||||
|
q.single(world).cloned().unwrap_or_else(|_| {
|
||||||
|
tracing::warn!("save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph");
|
||||||
|
KnowledgeGraph::new()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global NPC social web
|
||||||
|
let relationship_graph = world.resource::<RelationshipGraph>().clone();
|
||||||
|
|
||||||
|
// Per-NPC states: collect then sort by stable_id (D-010 determinism)
|
||||||
|
let npc_entities: Vec<Entity> = {
|
||||||
|
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||||
|
q.iter(world).collect()
|
||||||
|
};
|
||||||
|
let mut npc_states: Vec<_> = npc_entities
|
||||||
|
.iter()
|
||||||
|
.map(|&entity| serialize_npc_to_frozen(entity, world))
|
||||||
|
.collect();
|
||||||
|
npc_states.sort_by_key(|s| s.stable_id.0);
|
||||||
|
|
||||||
|
let npc_count = npc_states.len();
|
||||||
|
let state = SaveStateV1 {
|
||||||
|
format_version: SAVE_FORMAT_VERSION,
|
||||||
|
tick,
|
||||||
|
tick_rate,
|
||||||
|
seed,
|
||||||
|
player_knowledge,
|
||||||
|
relationship_graph,
|
||||||
|
npc_states,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = state
|
||||||
|
.to_bytes()
|
||||||
|
.map_err(|e| SaveLoadError::Serialize(e.to_string()))?;
|
||||||
|
|
||||||
|
std::fs::write(path, &bytes).map_err(|e| SaveLoadError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"save_to_file: {:?} (tick={}, npcs={}, {} bytes)",
|
||||||
|
path,
|
||||||
|
tick,
|
||||||
|
npc_count,
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `path`, deserialise `SaveStateV1`, and re-inject state into the ECS (#553).
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Read and deserialise bytes; reject if `format_version != SAVE_FORMAT_VERSION`.
|
||||||
|
/// 2. Despawn all existing NPC entities and unregister them from `EntityRegistry`.
|
||||||
|
/// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with
|
||||||
|
/// `register_existing`; insert `BackgroundSim` tier marker.
|
||||||
|
/// 4. Advance `EntityRegistry` counter past all restored IDs.
|
||||||
|
/// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources.
|
||||||
|
/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists.
|
||||||
|
///
|
||||||
|
/// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses
|
||||||
|
/// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the
|
||||||
|
/// new `Entity` handles after re-spawn.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `SaveLoadError::Io` on filesystem failure.
|
||||||
|
/// `SaveLoadError::Deserialize` on MessagePack decoding failure.
|
||||||
|
/// `SaveLoadError::VersionMismatch` when the save file predates the current schema.
|
||||||
|
pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| SaveLoadError::Io(e.to_string()))?;
|
||||||
|
let state =
|
||||||
|
SaveStateV1::from_bytes(&bytes).map_err(|e| SaveLoadError::Deserialize(e.to_string()))?;
|
||||||
|
|
||||||
|
if state.format_version != SAVE_FORMAT_VERSION {
|
||||||
|
return Err(SaveLoadError::VersionMismatch {
|
||||||
|
expected: SAVE_FORMAT_VERSION,
|
||||||
|
found: state.format_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let npc_count = state.npc_states.len();
|
||||||
|
|
||||||
|
// Despawn all existing NPC entities and clear their registry entries.
|
||||||
|
let npc_entities: Vec<Entity> = {
|
||||||
|
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||||
|
q.iter(world).collect()
|
||||||
|
};
|
||||||
|
for entity in npc_entities {
|
||||||
|
world.resource_mut::<EntityRegistry>().unregister(entity);
|
||||||
|
world.despawn(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the highest restored StableId so we can advance the counter.
|
||||||
|
let mut max_id: u64 = 0;
|
||||||
|
|
||||||
|
// Re-spawn NPCs, assign tier marker, register pre-existing StableIds.
|
||||||
|
for npc_state in &state.npc_states {
|
||||||
|
let entity = deserialize_npc_from_frozen(npc_state, world);
|
||||||
|
|
||||||
|
// Loaded NPCs start in BackgroundSim; the distance system promotes as needed.
|
||||||
|
world.entity_mut(entity).insert(BackgroundSim);
|
||||||
|
|
||||||
|
let stable_id = npc_state.stable_id;
|
||||||
|
world
|
||||||
|
.resource_mut::<EntityRegistry>()
|
||||||
|
.register_existing(entity, stable_id);
|
||||||
|
|
||||||
|
max_id = max_id.max(stable_id.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the registry counter past all restored IDs so future register()
|
||||||
|
// calls produce non-conflicting IDs.
|
||||||
|
if npc_count > 0 {
|
||||||
|
world.resource_mut::<EntityRegistry>().advance_past(max_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore simulation resources.
|
||||||
|
world.insert_resource(state.relationship_graph);
|
||||||
|
{
|
||||||
|
let mut t = world.resource_mut::<SimulationTime>();
|
||||||
|
t.tick = state.tick;
|
||||||
|
t.tick_rate = state.tick_rate;
|
||||||
|
}
|
||||||
|
world.insert_resource(SimRng::new(state.seed));
|
||||||
|
|
||||||
|
// Update the player entity's KnowledgeGraph if a player exists.
|
||||||
|
let player_entity = {
|
||||||
|
let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>();
|
||||||
|
q.single(world).ok()
|
||||||
|
};
|
||||||
|
if let Some(player_entity) = player_entity {
|
||||||
|
world
|
||||||
|
.entity_mut(player_entity)
|
||||||
|
.insert(state.player_knowledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"load_from_file: {:?} (tick={}, npcs={})",
|
||||||
|
path,
|
||||||
|
state.tick,
|
||||||
|
npc_count,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exclusive system: drain `SaveLoadPending` and execute queued save/load (#553).
|
||||||
|
///
|
||||||
|
/// Runs each tick, after `process_player_input`. If a command is pending,
|
||||||
|
/// executes it and writes `SaveLoadResultWire` to `SnapshotBuffer.pending_save_result`
|
||||||
|
/// for consumption by `compute_observer_snapshot` the same tick.
|
||||||
|
pub fn execute_save_load(world: &mut World) {
|
||||||
|
// Take the pending command (releases the borrow before we use world again).
|
||||||
|
let command = {
|
||||||
|
let mut pending = world.resource_mut::<SaveLoadPending>();
|
||||||
|
pending.pending.take()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(command) = command else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (kind_str, result) = match &command {
|
||||||
|
SaveLoadCommand::Save { path } => {
|
||||||
|
let r = save_to_file(path, world);
|
||||||
|
("save", r)
|
||||||
|
}
|
||||||
|
SaveLoadCommand::Load { path } => {
|
||||||
|
let r = load_from_file(path, world);
|
||||||
|
("load", r)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let wire_result = match result {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!("execute_save_load: {} completed", kind_str);
|
||||||
|
SaveLoadResultWire {
|
||||||
|
success: true,
|
||||||
|
kind: kind_str.to_string(),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e) => {
|
||||||
|
tracing::error!("execute_save_load: {} failed: {}", kind_str, e);
|
||||||
|
SaveLoadResultWire {
|
||||||
|
success: false,
|
||||||
|
kind: kind_str.to_string(),
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write result to SnapshotBuffer for client feedback (one tick only — consumed by
|
||||||
|
// compute_observer_snapshot via pending_save_result.take()).
|
||||||
|
if let Some(mut buf) = world.get_resource_mut::<SnapshotBuffer>() {
|
||||||
|
buf.pending_save_result = Some(wire_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
|
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
use crate::npc::Npc;
|
||||||
|
use crate::npc::relationships::RelationshipGraph;
|
||||||
|
use crate::simulation::movement::TilePosition;
|
||||||
|
use crate::simulation::rng::SimRng;
|
||||||
|
use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION};
|
||||||
|
use crate::simulation::time::{SimulationTime, TickRate};
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn temp_path() -> PathBuf {
|
||||||
|
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minimal_world() -> World {
|
||||||
|
let mut w = World::new();
|
||||||
|
w.insert_resource(SimulationTime::default());
|
||||||
|
w.insert_resource(SimRng::new(42));
|
||||||
|
w.insert_resource(RelationshipGraph::new());
|
||||||
|
w.init_resource::<EntityRegistry>();
|
||||||
|
w
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_test_npc(world: &mut World, stable_id: u64) -> Entity {
|
||||||
|
world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
StableEntityId(StableId(stable_id)),
|
||||||
|
TilePosition::new(stable_id as i32, 0, 0),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// save_to_file
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_file_creates_valid_msgpack() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
spawn_test_npc(&mut world, 1);
|
||||||
|
spawn_test_npc(&mut world, 2);
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save should succeed");
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).expect("file should exist");
|
||||||
|
let state = SaveStateV1::from_bytes(&bytes).expect("bytes must be valid msgpack");
|
||||||
|
assert_eq!(state.format_version, SAVE_FORMAT_VERSION);
|
||||||
|
assert_eq!(state.npc_states.len(), 2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_file_sorts_npc_states_by_stable_id() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
// Spawn in reverse order — save should still sort ascending
|
||||||
|
spawn_test_npc(&mut world, 50);
|
||||||
|
spawn_test_npc(&mut world, 10);
|
||||||
|
spawn_test_npc(&mut world, 30);
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save should succeed");
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).expect("read saved file");
|
||||||
|
let state = SaveStateV1::from_bytes(&bytes).unwrap();
|
||||||
|
let ids: Vec<u64> = state.npc_states.iter().map(|n| n.stable_id.0).collect();
|
||||||
|
assert_eq!(ids, vec![10, 30, 50], "npc_states must be sorted by stable_id");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_file_preserves_tick_and_seed() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
{
|
||||||
|
let mut t = world.resource_mut::<SimulationTime>();
|
||||||
|
t.tick = 9999;
|
||||||
|
t.tick_rate = TickRate::Half;
|
||||||
|
}
|
||||||
|
world.insert_resource(SimRng::new(0xDEADBEEF));
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save");
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let state = SaveStateV1::from_bytes(&bytes).unwrap();
|
||||||
|
assert_eq!(state.tick, 9999);
|
||||||
|
assert_eq!(state.tick_rate, TickRate::Half);
|
||||||
|
assert_eq!(state.seed, 0xDEADBEEF);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_file_returns_io_error_on_bad_path() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
let bad_path = std::path::Path::new("/nonexistent/directory/save.msgpack");
|
||||||
|
let result = save_to_file(bad_path, &mut world);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(SaveLoadError::Io(_))),
|
||||||
|
"expected Io error for bad path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// load_from_file
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_restores_npc_count() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
spawn_test_npc(&mut world, 1);
|
||||||
|
spawn_test_npc(&mut world, 2);
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save");
|
||||||
|
|
||||||
|
// Spawn an extra NPC — loading should despawn the old NPCs and restore exactly 2
|
||||||
|
spawn_test_npc(&mut world, 99);
|
||||||
|
let pre_load_count = {
|
||||||
|
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||||
|
q.iter(&world).count()
|
||||||
|
};
|
||||||
|
assert_eq!(pre_load_count, 3, "three NPCs before load");
|
||||||
|
|
||||||
|
load_from_file(&path, &mut world).expect("load");
|
||||||
|
|
||||||
|
let post_load_count = {
|
||||||
|
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||||
|
q.iter(&world).count()
|
||||||
|
};
|
||||||
|
assert_eq!(post_load_count, 2, "exactly the two saved NPCs after load");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_restores_stable_ids_in_registry() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
spawn_test_npc(&mut world, 10);
|
||||||
|
spawn_test_npc(&mut world, 20);
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save");
|
||||||
|
load_from_file(&path, &mut world).expect("load");
|
||||||
|
|
||||||
|
let registry = world.resource::<EntityRegistry>();
|
||||||
|
assert!(
|
||||||
|
registry.to_entity(&StableId(10)).is_some(),
|
||||||
|
"StableId(10) must be in registry after load"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registry.to_entity(&StableId(20)).is_some(),
|
||||||
|
"StableId(20) must be in registry after load"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_restores_tick_and_seed() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
{
|
||||||
|
let mut t = world.resource_mut::<SimulationTime>();
|
||||||
|
t.tick = 5000;
|
||||||
|
}
|
||||||
|
world.insert_resource(SimRng::new(12345));
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save");
|
||||||
|
|
||||||
|
// Change time and seed, then load
|
||||||
|
{
|
||||||
|
let mut t = world.resource_mut::<SimulationTime>();
|
||||||
|
t.tick = 1;
|
||||||
|
}
|
||||||
|
world.insert_resource(SimRng::new(0));
|
||||||
|
|
||||||
|
load_from_file(&path, &mut world).expect("load");
|
||||||
|
|
||||||
|
let t = world.resource::<SimulationTime>();
|
||||||
|
assert_eq!(t.tick, 5000, "tick restored from save");
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<SimRng>().seed(),
|
||||||
|
12345,
|
||||||
|
"seed restored from save"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_rejects_wrong_format_version() {
|
||||||
|
// Craft a save with a wrong format_version
|
||||||
|
let bad_state = SaveStateV1 {
|
||||||
|
format_version: 0xFF, // deliberately wrong
|
||||||
|
tick: 0,
|
||||||
|
tick_rate: TickRate::Full,
|
||||||
|
seed: 0,
|
||||||
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
|
relationship_graph: RelationshipGraph::new(),
|
||||||
|
npc_states: vec![],
|
||||||
|
};
|
||||||
|
let bytes = bad_state.to_bytes().expect("serialize");
|
||||||
|
let path = temp_path();
|
||||||
|
std::fs::write(&path, &bytes).expect("write");
|
||||||
|
|
||||||
|
let mut world = minimal_world();
|
||||||
|
let result = load_from_file(&path, &mut world);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(SaveLoadError::VersionMismatch { .. })),
|
||||||
|
"expected VersionMismatch error, got {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_returns_io_error_for_missing_file() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
let missing = std::path::Path::new("/tmp/settled_reach_nonexistent_42.msgpack");
|
||||||
|
let result = load_from_file(missing, &mut world);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(SaveLoadError::Io(_))),
|
||||||
|
"expected Io error for missing file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_file_assigns_background_sim_tier() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
spawn_test_npc(&mut world, 1);
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
save_to_file(&path, &mut world).expect("save");
|
||||||
|
load_from_file(&path, &mut world).expect("load");
|
||||||
|
|
||||||
|
let has_background: bool = {
|
||||||
|
let mut q = world.query_filtered::<Entity, (With<Npc>, With<BackgroundSim>)>();
|
||||||
|
q.iter(&world).count() > 0
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
has_background,
|
||||||
|
"loaded NPC should be in BackgroundSim tier"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// execute_save_load
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_save_load_noop_when_no_pending() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
world.init_resource::<SaveLoadPending>();
|
||||||
|
world.init_resource::<SnapshotBuffer>();
|
||||||
|
|
||||||
|
execute_save_load(&mut world);
|
||||||
|
|
||||||
|
// No result written when no pending command
|
||||||
|
let buf = world.resource::<SnapshotBuffer>();
|
||||||
|
assert!(
|
||||||
|
buf.pending_save_result.is_none(),
|
||||||
|
"no pending_save_result when no command was queued"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_save_load_writes_success_result() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
world.init_resource::<SnapshotBuffer>();
|
||||||
|
|
||||||
|
let path = temp_path();
|
||||||
|
world.insert_resource(SaveLoadPending {
|
||||||
|
pending: Some(SaveLoadCommand::Save { path: path.clone() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
execute_save_load(&mut world);
|
||||||
|
|
||||||
|
let buf = world.resource::<SnapshotBuffer>();
|
||||||
|
let result = buf
|
||||||
|
.pending_save_result
|
||||||
|
.as_ref()
|
||||||
|
.expect("result must be written after execute");
|
||||||
|
assert!(result.success, "save should succeed");
|
||||||
|
assert_eq!(result.kind, "save");
|
||||||
|
assert!(result.error.is_none());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_save_load_writes_error_result_on_bad_path() {
|
||||||
|
let mut world = minimal_world();
|
||||||
|
world.init_resource::<SnapshotBuffer>();
|
||||||
|
|
||||||
|
world.insert_resource(SaveLoadPending {
|
||||||
|
pending: Some(SaveLoadCommand::Save {
|
||||||
|
path: PathBuf::from("/nonexistent/dir/save.msgpack"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
execute_save_load(&mut world);
|
||||||
|
|
||||||
|
let buf = world.resource::<SnapshotBuffer>();
|
||||||
|
let result = buf
|
||||||
|
.pending_save_result
|
||||||
|
.as_ref()
|
||||||
|
.expect("result must be written even on failure");
|
||||||
|
assert!(!result.success, "save should fail with bad path");
|
||||||
|
assert_eq!(result.kind, "save");
|
||||||
|
assert!(result.error.is_some(), "error message should be present");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Overwrite behaviour
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// When two commands arrive in the same tick, the second overwrites the first.
|
||||||
|
/// The warn! in process_player_input fires; here we just confirm last-write-wins.
|
||||||
|
#[test]
|
||||||
|
fn pending_command_overwrite_last_write_wins() {
|
||||||
|
let mut pending = SaveLoadPending::default();
|
||||||
|
|
||||||
|
pending.pending = Some(SaveLoadCommand::Save {
|
||||||
|
path: PathBuf::from("/tmp/first.msgpack"),
|
||||||
|
});
|
||||||
|
// Overwrite with a Load command
|
||||||
|
pending.pending = Some(SaveLoadCommand::Load {
|
||||||
|
path: PathBuf::from("/tmp/second.msgpack"),
|
||||||
|
});
|
||||||
|
|
||||||
|
match pending.pending.unwrap() {
|
||||||
|
SaveLoadCommand::Load { ref path } => {
|
||||||
|
assert_eq!(path.to_str().unwrap(), "/tmp/second.msgpack");
|
||||||
|
}
|
||||||
|
other => panic!("expected Load, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SaveLoadError display
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_load_error_display() {
|
||||||
|
let e = SaveLoadError::Io("disk full".into());
|
||||||
|
assert!(e.to_string().contains("disk full"));
|
||||||
|
|
||||||
|
let e2 = SaveLoadError::VersionMismatch {
|
||||||
|
expected: 1,
|
||||||
|
found: 2,
|
||||||
|
};
|
||||||
|
assert!(e2.to_string().contains("expected 1"));
|
||||||
|
assert!(e2.to_string().contains("found 2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
//! Save state data model (#256, D-010).
|
||||||
|
//!
|
||||||
|
//! `SaveStateV1` is the versioned serialization envelope for full game state.
|
||||||
|
//! Shares architecture with #96 (state serialization system) — this module
|
||||||
|
//! defines the data model AND the per-NPC serialization primitives for tier
|
||||||
|
//! eviction freeze/thaw (#96).
|
||||||
|
//!
|
||||||
|
//! ## Write format: MessagePack
|
||||||
|
//!
|
||||||
|
//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020).
|
||||||
|
//! Both the IPC protocol and save files use the same codec for simplicity.
|
||||||
|
//! RON/YAML alternatives were considered — MessagePack chosen for consistency.
|
||||||
|
//! Human-readable debug output can be derived via the Debug impl or a separate
|
||||||
|
//! conversion step; a full RON bridge is deferred beyond v0.1.
|
||||||
|
//!
|
||||||
|
//! ## Versioning strategy
|
||||||
|
//!
|
||||||
|
//! `format_version: u8` bumps on breaking schema changes. Loader checks version
|
||||||
|
//! and rejects incompatible saves. `serde(default)` on optional new fields allows
|
||||||
|
//! forward-compatible extensions within the same major version.
|
||||||
|
//!
|
||||||
|
//! ## What is captured (v0.1 scope)
|
||||||
|
//!
|
||||||
|
//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction
|
||||||
|
//! - RNG seed: reproduce the same random sequence on load (D-010)
|
||||||
|
//! - Player knowledge graph: the observer's epistemics at save time
|
||||||
|
//! - Global relationship graph: the NPC social web (resource, not per-entity)
|
||||||
|
//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue
|
||||||
|
//!
|
||||||
|
//! ## Not yet captured (deferred to #257 and beyond)
|
||||||
|
//!
|
||||||
|
//! - Full ECS world extraction/injection (system not yet written)
|
||||||
|
//! - Pathfinding state (reconstructed from position + routine)
|
||||||
|
//! - Tier transitions in-flight (dropped to background state on load)
|
||||||
|
//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after
|
||||||
|
//! reload; memory degrades naturally over time so reset-on-load is acceptable)
|
||||||
|
|
||||||
|
use bevy_ecs::entity::Entity;
|
||||||
|
use bevy_ecs::world::World;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
|
use crate::knowledge::registry::StableEntityId;
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
use crate::npc::{
|
||||||
|
CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc,
|
||||||
|
PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem,
|
||||||
|
ToleranceThreshold, Want, WantKind,
|
||||||
|
};
|
||||||
|
use crate::npc::awareness::PlayerAwareness;
|
||||||
|
use crate::npc::mood::MoodState;
|
||||||
|
use crate::npc::relationships::RelationshipGraph;
|
||||||
|
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||||
|
use crate::simulation::movement::TilePosition;
|
||||||
|
use crate::simulation::time::TickRate;
|
||||||
|
|
||||||
|
/// Current format version. Bump on any breaking schema change.
|
||||||
|
pub const SAVE_FORMAT_VERSION: u8 = 1;
|
||||||
|
|
||||||
|
/// Top-level save state envelope (#256, D-010).
|
||||||
|
///
|
||||||
|
/// Serialized with MessagePack (rmp_serde) for storage. Load with
|
||||||
|
/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`.
|
||||||
|
///
|
||||||
|
/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION`
|
||||||
|
/// before trusting content.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SaveStateV1 {
|
||||||
|
/// Format version. Must equal `SAVE_FORMAT_VERSION` on load.
|
||||||
|
pub format_version: u8,
|
||||||
|
/// Simulation tick at the moment of save.
|
||||||
|
pub tick: u64,
|
||||||
|
/// Active tick rate at save time (Full/Half/Paused).
|
||||||
|
pub tick_rate: TickRate,
|
||||||
|
/// RNG seed active at save time for deterministic replay (D-010).
|
||||||
|
/// On load, seed the RNG from this value before advancing any ticks.
|
||||||
|
pub seed: u64,
|
||||||
|
/// Player character knowledge graph — the observer's epistemics at save time.
|
||||||
|
pub player_knowledge: KnowledgeGraph,
|
||||||
|
/// Global NPC relationship graph resource.
|
||||||
|
/// Serialized as a unit: all directed edges between NPCs and player.
|
||||||
|
pub relationship_graph: RelationshipGraph,
|
||||||
|
/// Per-NPC summary state for each simulated NPC.
|
||||||
|
/// Order is deterministic (sorted by stable_id in ascending order).
|
||||||
|
pub npc_states: Vec<NpcSaveState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||||
|
///
|
||||||
|
/// Two usage contexts:
|
||||||
|
/// 1. **Whole-game save** (`SaveStateV1.npc_states`): populated by #553 ECS extraction.
|
||||||
|
/// Only the core axis fields need to be populated for this use case.
|
||||||
|
/// 2. **Tier eviction freeze** (produced by `serialize_npc_to_frozen`): captures ALL
|
||||||
|
/// components needed for full NPC reconstruction from `StateSaved` tier.
|
||||||
|
/// The extended optional fields (#96) carry all 10 D-024 axes.
|
||||||
|
///
|
||||||
|
/// All fields added post-#256 use `#[serde(default)]` for forward compatibility
|
||||||
|
/// with older save files that predate these fields.
|
||||||
|
///
|
||||||
|
/// ## D-024 axis coverage
|
||||||
|
/// | Axis | Field | Status |
|
||||||
|
/// |------|-------|--------|
|
||||||
|
/// | 1: Want | `want` | Full (optional for backward compat) |
|
||||||
|
/// | 2: Secret | `secret_severity` (legacy) + `secret` | Full |
|
||||||
|
/// | 3: Relationships | `relationships` | Full |
|
||||||
|
/// | 4: Tolerance | `current_stress` + `tolerance_threshold` | Full |
|
||||||
|
/// | 5: Daily routine | `routine` | Full (optional) |
|
||||||
|
/// | 6: Information inventory | `information_inventory` | Full (optional) |
|
||||||
|
/// | 7: Contentment | `contentment` | Full |
|
||||||
|
/// | Supporting 1: Personality | `personality_traits` | Full (optional) |
|
||||||
|
/// | Supporting 2: Tells | `tell_system` | Full (optional) |
|
||||||
|
/// | Supporting 3: Skills | `skill_set` + `combat_capability` | Full (optional) |
|
||||||
|
///
|
||||||
|
/// ## Components intentionally NOT serialized
|
||||||
|
/// - `NpcVisionState`: runtime LOS state, reset to default on reactivation
|
||||||
|
/// - `NpcMemory`: stale inferences would be wrong after reload (intentional drop)
|
||||||
|
/// - `PlayerAwareness`: runtime derived state, reset to default on reactivation
|
||||||
|
/// - `AnimationTier`: resets to `Tier1` on reactivation (no persistent state)
|
||||||
|
/// - `RoutineDeviation`: transient event marker, acceptable to drop on reload
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NpcSaveState {
|
||||||
|
/// Stable entity identifier (survives serialization — D-020).
|
||||||
|
pub stable_id: StableId,
|
||||||
|
/// Last known tile position.
|
||||||
|
pub position: TilePosition,
|
||||||
|
|
||||||
|
// Axis 2: Secret severity (legacy field — description regenerated from content on load).
|
||||||
|
// Kept for backward compatibility. Prefer `secret` field when doing full reconstruction.
|
||||||
|
pub secret_severity: SecretSeverity,
|
||||||
|
// Axis 3: Per-NPC relationship slots
|
||||||
|
pub relationships: Option<Relationships>,
|
||||||
|
// Axis 4: Tolerance — current stress level at save time
|
||||||
|
pub current_stress: i16,
|
||||||
|
/// Tolerance threshold value (does not change at runtime).
|
||||||
|
pub tolerance_threshold: i16,
|
||||||
|
// Axis 7: Contentment
|
||||||
|
pub contentment: i16,
|
||||||
|
|
||||||
|
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
|
||||||
|
pub knowledge_graph: Option<KnowledgeGraph>,
|
||||||
|
|
||||||
|
// --- Full reconstruction fields (added #96, for tier eviction freeze) ---
|
||||||
|
// All fields below use serde(default) for backward compatibility with saves
|
||||||
|
// created before #96 shipped.
|
||||||
|
|
||||||
|
/// Axis 1: Want (primary drive, intensity, and description).
|
||||||
|
#[serde(default)]
|
||||||
|
pub want: Option<Want>,
|
||||||
|
|
||||||
|
/// Axis 2: Full secret (description + known_by list).
|
||||||
|
/// Supersedes `secret_severity` for full reconstruction.
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret: Option<Secret>,
|
||||||
|
|
||||||
|
/// Axis 5: Daily routine (phase → location schedule).
|
||||||
|
#[serde(default)]
|
||||||
|
pub routine: Option<DailyRoutine>,
|
||||||
|
|
||||||
|
/// Axis 6: Information inventory (facts this NPC carries).
|
||||||
|
#[serde(default)]
|
||||||
|
pub information_inventory: Option<InformationInventory>,
|
||||||
|
|
||||||
|
/// Supporting axis 1: Personality traits (2–3 traits, no contradictory pairs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub personality_traits: Option<PersonalityTraits>,
|
||||||
|
|
||||||
|
/// Supporting axis 2: Tell system (behavioral tells tied to stress/personality).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tell_system: Option<TellSystem>,
|
||||||
|
|
||||||
|
/// Supporting axis 3: Skill set (proficiency BTreeMap).
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill_set: Option<SkillSet>,
|
||||||
|
|
||||||
|
/// Optional combat capability (only present for combat-trained NPCs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub combat_capability: Option<CombatCapability>,
|
||||||
|
|
||||||
|
/// Mood state at save time. Derived from stress but worth preserving across
|
||||||
|
/// tier transitions to avoid jarring state resets on reactivation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mood_state: Option<MoodState>,
|
||||||
|
|
||||||
|
/// Job performance score — drifts over time, persist across tier transitions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub job_performance: Option<JobPerformance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveStateV1 {
|
||||||
|
/// Serialize to MessagePack bytes.
|
||||||
|
pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
|
||||||
|
rmp_serde::to_vec_named(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize from MessagePack bytes.
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
|
||||||
|
rmp_serde::from_slice(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-NPC tier eviction serialization primitives (#96)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Serialize a live NPC entity to a `NpcSaveState` frozen struct.
|
||||||
|
///
|
||||||
|
/// Used by the tier eviction system when demoting an entity to `StateSaved`:
|
||||||
|
/// instead of keeping all ECS components live, the entity is frozen and despawned.
|
||||||
|
/// The caller should despawn the entity after calling this function.
|
||||||
|
///
|
||||||
|
/// **Caller invariant:** The entity must have a `StableEntityId` component.
|
||||||
|
/// All other components are optional — missing components produce sensible defaults
|
||||||
|
/// in the output (and will be reconstructed as defaults by `deserialize_npc_from_frozen`).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if the entity has no `StableEntityId` component.
|
||||||
|
pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState {
|
||||||
|
let position = world
|
||||||
|
.get::<TilePosition>(entity)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| TilePosition::new(0, 0, 0));
|
||||||
|
|
||||||
|
let stable_id = world
|
||||||
|
.get::<StableEntityId>(entity)
|
||||||
|
.map(|s| s.0)
|
||||||
|
.expect("NPC entity must have StableEntityId before serialization (#96)");
|
||||||
|
|
||||||
|
let secret = world.get::<Secret>(entity).cloned();
|
||||||
|
let secret_severity = secret
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.severity)
|
||||||
|
.unwrap_or(SecretSeverity::Minor);
|
||||||
|
|
||||||
|
let (current_stress, tolerance_threshold) = world
|
||||||
|
.get::<ToleranceThreshold>(entity)
|
||||||
|
.map(|t| (t.current_stress, t.threshold))
|
||||||
|
.unwrap_or((0, 50));
|
||||||
|
|
||||||
|
NpcSaveState {
|
||||||
|
stable_id,
|
||||||
|
position,
|
||||||
|
secret_severity,
|
||||||
|
relationships: world.get::<Relationships>(entity).cloned(),
|
||||||
|
current_stress,
|
||||||
|
tolerance_threshold,
|
||||||
|
contentment: world
|
||||||
|
.get::<Contentment>(entity)
|
||||||
|
.map(|c| c.level)
|
||||||
|
.unwrap_or(0),
|
||||||
|
knowledge_graph: world.get::<KnowledgeGraph>(entity).cloned(),
|
||||||
|
want: world.get::<Want>(entity).cloned(),
|
||||||
|
secret,
|
||||||
|
routine: world.get::<DailyRoutine>(entity).cloned(),
|
||||||
|
information_inventory: world.get::<InformationInventory>(entity).cloned(),
|
||||||
|
personality_traits: world.get::<PersonalityTraits>(entity).cloned(),
|
||||||
|
tell_system: world.get::<TellSystem>(entity).cloned(),
|
||||||
|
skill_set: world.get::<SkillSet>(entity).cloned(),
|
||||||
|
combat_capability: world.get::<CombatCapability>(entity).cloned(),
|
||||||
|
mood_state: world.get::<MoodState>(entity).cloned(),
|
||||||
|
job_performance: world.get::<JobPerformance>(entity).cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a frozen `NpcSaveState` and re-spawn a full NPC entity.
|
||||||
|
///
|
||||||
|
/// Used by the tier eviction system when reactivating an entity from `StateSaved`.
|
||||||
|
/// Reconstructs all D-024 axis components from the frozen state.
|
||||||
|
///
|
||||||
|
/// **Caller responsibilities after calling this function:**
|
||||||
|
/// 1. Register the returned `Entity` with `EntityRegistry` (StableId→Entity mapping).
|
||||||
|
/// 2. Assign the appropriate tier marker (`ActiveSim` or `BackgroundSim`).
|
||||||
|
///
|
||||||
|
/// Optional fields that are absent in `state` are reconstructed with sensible defaults:
|
||||||
|
/// - `Want`: defaults to `Safety` at intensity 5 (conservative non-disruptive default)
|
||||||
|
/// - `Secret`: reconstructed from `secret_severity` with empty description
|
||||||
|
/// - `MoodState`, `JobPerformance`: their `Default` implementations
|
||||||
|
///
|
||||||
|
/// Components excluded from reconstruction (see `NpcSaveState` doc for rationale):
|
||||||
|
/// `NpcVisionState`, `NpcMemory`, `PlayerAwareness` are reset to their `Default` states.
|
||||||
|
pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> Entity {
|
||||||
|
let want = state.want.clone().unwrap_or(Want {
|
||||||
|
primary: WantKind::Safety, // conservative fallback — see flag comment above
|
||||||
|
intensity: 5,
|
||||||
|
description: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let secret = state.secret.clone().unwrap_or(crate::npc::Secret {
|
||||||
|
description: String::new(),
|
||||||
|
severity: state.secret_severity,
|
||||||
|
known_by: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
let relationships = state
|
||||||
|
.relationships
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(Relationships { entries: vec![] });
|
||||||
|
|
||||||
|
let tolerance = ToleranceThreshold {
|
||||||
|
current_stress: state.current_stress,
|
||||||
|
threshold: state.tolerance_threshold,
|
||||||
|
};
|
||||||
|
|
||||||
|
let contentment = Contentment {
|
||||||
|
level: state.contentment,
|
||||||
|
};
|
||||||
|
|
||||||
|
let kg = state
|
||||||
|
.knowledge_graph
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(KnowledgeGraph::new);
|
||||||
|
|
||||||
|
// Spawn the entity with all required components. Tier marker (ActiveSim /
|
||||||
|
// BackgroundSim) is NOT added here — the caller assigns it after registration.
|
||||||
|
let entity = world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
state.position,
|
||||||
|
StableEntityId(state.stable_id),
|
||||||
|
want,
|
||||||
|
secret,
|
||||||
|
relationships,
|
||||||
|
tolerance,
|
||||||
|
contentment,
|
||||||
|
kg,
|
||||||
|
state.mood_state.clone().unwrap_or_default(),
|
||||||
|
state.job_performance.clone().unwrap_or_default(),
|
||||||
|
// Runtime-computed components: reset to default on reactivation.
|
||||||
|
NpcVisionState::default(),
|
||||||
|
NpcMemory::default(),
|
||||||
|
PlayerAwareness::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
// Optional axis components — insert only if present in frozen state.
|
||||||
|
{
|
||||||
|
let mut em = world.entity_mut(entity);
|
||||||
|
|
||||||
|
if let Some(routine) = state.routine.clone() {
|
||||||
|
em.insert(routine);
|
||||||
|
}
|
||||||
|
if let Some(inventory) = state.information_inventory.clone() {
|
||||||
|
em.insert(inventory);
|
||||||
|
}
|
||||||
|
if let Some(traits) = state.personality_traits.clone() {
|
||||||
|
em.insert(traits);
|
||||||
|
}
|
||||||
|
if let Some(tells) = state.tell_system.clone() {
|
||||||
|
em.insert(tells);
|
||||||
|
}
|
||||||
|
if let Some(skills) = state.skill_set.clone() {
|
||||||
|
em.insert(skills);
|
||||||
|
}
|
||||||
|
if let Some(combat) = state.combat_capability.clone() {
|
||||||
|
em.insert(combat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
|
use crate::knowledge::registry::StableEntityId;
|
||||||
|
use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId};
|
||||||
|
use crate::npc::relationships::RelationshipGraph;
|
||||||
|
use crate::simulation::movement::TilePosition;
|
||||||
|
use crate::simulation::time::TickRate;
|
||||||
|
|
||||||
|
fn minimal_save_state() -> SaveStateV1 {
|
||||||
|
SaveStateV1 {
|
||||||
|
format_version: SAVE_FORMAT_VERSION,
|
||||||
|
tick: 0,
|
||||||
|
tick_rate: TickRate::Full,
|
||||||
|
seed: 42,
|
||||||
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
|
relationship_graph: RelationshipGraph::new(),
|
||||||
|
npc_states: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Roundtrip tests: serialize → deserialize → re-serialize → bytes match
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_save_state_roundtrips() {
|
||||||
|
// Spec (#256): roundtrip must produce identical state.
|
||||||
|
// Strategy: bytes(original) == bytes(roundtrip(original))
|
||||||
|
let state = minimal_save_state();
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||||
|
assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_version_preserved_in_roundtrip() {
|
||||||
|
let state = minimal_save_state();
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
assert_eq!(
|
||||||
|
recovered.format_version, SAVE_FORMAT_VERSION,
|
||||||
|
"format version must survive roundtrip"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tick_and_seed_preserved_in_roundtrip() {
|
||||||
|
let mut state = minimal_save_state();
|
||||||
|
state.tick = 12345;
|
||||||
|
state.seed = 0xDEADBEEF;
|
||||||
|
state.tick_rate = TickRate::Half;
|
||||||
|
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
|
||||||
|
assert_eq!(recovered.tick, 12345);
|
||||||
|
assert_eq!(recovered.seed, 0xDEADBEEF);
|
||||||
|
assert_eq!(recovered.tick_rate, TickRate::Half);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_states_roundtrip_with_position_and_axes() {
|
||||||
|
// Spec (#256): per-NPC D-024 axis values must survive serialization.
|
||||||
|
let mut state = minimal_save_state();
|
||||||
|
state.npc_states = vec![
|
||||||
|
NpcSaveState {
|
||||||
|
stable_id: StableId(101),
|
||||||
|
position: TilePosition::new(10, 20, 0),
|
||||||
|
secret_severity: crate::npc::SecretSeverity::Major,
|
||||||
|
relationships: None,
|
||||||
|
current_stress: 45,
|
||||||
|
tolerance_threshold: 80,
|
||||||
|
contentment: -15,
|
||||||
|
knowledge_graph: None,
|
||||||
|
want: None,
|
||||||
|
secret: None,
|
||||||
|
routine: None,
|
||||||
|
information_inventory: None,
|
||||||
|
personality_traits: None,
|
||||||
|
tell_system: None,
|
||||||
|
skill_set: None,
|
||||||
|
combat_capability: None,
|
||||||
|
mood_state: None,
|
||||||
|
job_performance: None,
|
||||||
|
},
|
||||||
|
NpcSaveState {
|
||||||
|
stable_id: StableId(202),
|
||||||
|
position: TilePosition::new(5, 5, 1),
|
||||||
|
secret_severity: crate::npc::SecretSeverity::Minor,
|
||||||
|
relationships: None,
|
||||||
|
current_stress: 0,
|
||||||
|
tolerance_threshold: 60,
|
||||||
|
contentment: 30,
|
||||||
|
knowledge_graph: None,
|
||||||
|
want: None,
|
||||||
|
secret: None,
|
||||||
|
routine: None,
|
||||||
|
information_inventory: None,
|
||||||
|
personality_traits: None,
|
||||||
|
tell_system: None,
|
||||||
|
skill_set: None,
|
||||||
|
combat_capability: None,
|
||||||
|
mood_state: None,
|
||||||
|
job_performance: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
|
||||||
|
assert_eq!(recovered.npc_states.len(), 2);
|
||||||
|
|
||||||
|
let npc1 = &recovered.npc_states[0];
|
||||||
|
assert_eq!(npc1.stable_id, StableId(101));
|
||||||
|
assert_eq!(npc1.position, TilePosition::new(10, 20, 0));
|
||||||
|
assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major);
|
||||||
|
assert_eq!(npc1.current_stress, 45);
|
||||||
|
assert_eq!(npc1.tolerance_threshold, 80);
|
||||||
|
assert_eq!(npc1.contentment, -15);
|
||||||
|
|
||||||
|
let npc2 = &recovered.npc_states[1];
|
||||||
|
assert_eq!(npc2.stable_id, StableId(202));
|
||||||
|
assert_eq!(npc2.contentment, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_knowledge_graph_roundtrips() {
|
||||||
|
// Spec (#256): KnowledgeGraph is "already serializable" — verify it
|
||||||
|
// survives a save state roundtrip intact.
|
||||||
|
use crate::knowledge::types::{KnowledgeSource, KnowledgeState};
|
||||||
|
use crate::simulation::movement::TilePosition;
|
||||||
|
|
||||||
|
let mut state = minimal_save_state();
|
||||||
|
let mut kg = KnowledgeGraph::new();
|
||||||
|
kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5);
|
||||||
|
|
||||||
|
// Add a fact
|
||||||
|
kg.facts.insert(
|
||||||
|
FactId("contraband.ring_exists".into()),
|
||||||
|
FactKnowledge {
|
||||||
|
confidence: KnowledgeConfidence::KnowsOf,
|
||||||
|
source: KnowledgeSource::Background,
|
||||||
|
state: KnowledgeState::Active,
|
||||||
|
acquired_tick: 100,
|
||||||
|
disclosure_blocked: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.player_knowledge = kg;
|
||||||
|
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
|
||||||
|
// Roundtrip the recovered state again — bytes must still match
|
||||||
|
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||||
|
assert_eq!(
|
||||||
|
bytes, bytes2,
|
||||||
|
"KnowledgeGraph roundtrip must be idempotent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relationship_graph_roundtrips() {
|
||||||
|
use crate::npc::relationships::RelationshipEdge;
|
||||||
|
use crate::npc::RelationshipKind;
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
|
||||||
|
let mut state = minimal_save_state();
|
||||||
|
let mut rg = RelationshipGraph::new();
|
||||||
|
rg.set_relationship(
|
||||||
|
StableId(1),
|
||||||
|
StableId(2),
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Friend,
|
||||||
|
trust: 5,
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.relationship_graph = rg;
|
||||||
|
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||||
|
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npc_with_knowledge_graph_roundtrips() {
|
||||||
|
// Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip.
|
||||||
|
let mut state = minimal_save_state();
|
||||||
|
let mut npc_kg = KnowledgeGraph::new();
|
||||||
|
npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10);
|
||||||
|
|
||||||
|
state.npc_states = vec![NpcSaveState {
|
||||||
|
stable_id: StableId(1),
|
||||||
|
position: TilePosition::new(1, 1, 0),
|
||||||
|
secret_severity: crate::npc::SecretSeverity::Minor,
|
||||||
|
relationships: None,
|
||||||
|
current_stress: 0,
|
||||||
|
tolerance_threshold: 50,
|
||||||
|
contentment: 0,
|
||||||
|
knowledge_graph: Some(npc_kg),
|
||||||
|
want: None,
|
||||||
|
secret: None,
|
||||||
|
routine: None,
|
||||||
|
information_inventory: None,
|
||||||
|
personality_traits: None,
|
||||||
|
tell_system: None,
|
||||||
|
skill_set: None,
|
||||||
|
combat_capability: None,
|
||||||
|
mood_state: None,
|
||||||
|
job_performance: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let bytes = state.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||||
|
assert_eq!(
|
||||||
|
bytes, bytes2,
|
||||||
|
"NPC KnowledgeGraph roundtrip must be lossless"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_format_version_constant_is_one() {
|
||||||
|
// Document the version explicitly so CI catches unintentional bumps.
|
||||||
|
assert_eq!(SAVE_FORMAT_VERSION, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Tier eviction serialization primitives (#96)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_minimal_npc(world: &mut World, stable_id: StableId) -> Entity {
|
||||||
|
use crate::npc::{
|
||||||
|
Contentment, Relationships, Secret, SecretSeverity, ToleranceThreshold, Want, WantKind,
|
||||||
|
};
|
||||||
|
use crate::npc::mood::MoodState;
|
||||||
|
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||||
|
use crate::npc::awareness::PlayerAwareness;
|
||||||
|
|
||||||
|
world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
StableEntityId(stable_id),
|
||||||
|
TilePosition::new(5, 10, 0),
|
||||||
|
Want {
|
||||||
|
primary: WantKind::Safety,
|
||||||
|
intensity: 7,
|
||||||
|
description: "wants safety".into(),
|
||||||
|
},
|
||||||
|
Secret {
|
||||||
|
description: "has a minor secret".into(),
|
||||||
|
severity: SecretSeverity::Minor,
|
||||||
|
known_by: vec![],
|
||||||
|
},
|
||||||
|
Relationships { entries: vec![] },
|
||||||
|
ToleranceThreshold {
|
||||||
|
current_stress: 30,
|
||||||
|
threshold: 70,
|
||||||
|
},
|
||||||
|
Contentment { level: 15 },
|
||||||
|
MoodState::default(),
|
||||||
|
crate::npc::JobPerformance::default(),
|
||||||
|
KnowledgeGraph::new(),
|
||||||
|
NpcVisionState::default(),
|
||||||
|
NpcMemory::default(),
|
||||||
|
PlayerAwareness::default(),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_npc_to_frozen_captures_stable_id_and_position() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let entity = spawn_minimal_npc(&mut world, StableId(42));
|
||||||
|
|
||||||
|
let frozen = serialize_npc_to_frozen(entity, &world);
|
||||||
|
|
||||||
|
assert_eq!(frozen.stable_id, StableId(42));
|
||||||
|
assert_eq!(frozen.position, TilePosition::new(5, 10, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_npc_to_frozen_captures_axes() {
|
||||||
|
use crate::npc::SecretSeverity;
|
||||||
|
let mut world = World::new();
|
||||||
|
let entity = spawn_minimal_npc(&mut world, StableId(1));
|
||||||
|
|
||||||
|
let frozen = serialize_npc_to_frozen(entity, &world);
|
||||||
|
|
||||||
|
assert_eq!(frozen.secret_severity, SecretSeverity::Minor);
|
||||||
|
assert_eq!(frozen.current_stress, 30);
|
||||||
|
assert_eq!(frozen.tolerance_threshold, 70);
|
||||||
|
assert_eq!(frozen.contentment, 15);
|
||||||
|
|
||||||
|
// Full optional axes should be populated when components exist
|
||||||
|
assert!(frozen.want.is_some(), "want should be captured");
|
||||||
|
assert!(frozen.secret.is_some(), "secret should be captured");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_deserialize_roundtrip_produces_identical_component_values() {
|
||||||
|
// Spec (#96): serialize + deserialize produces an entity with identical values.
|
||||||
|
let mut world = World::new();
|
||||||
|
let original = spawn_minimal_npc(&mut world, StableId(77));
|
||||||
|
|
||||||
|
// Serialize
|
||||||
|
let frozen = serialize_npc_to_frozen(original, &world);
|
||||||
|
|
||||||
|
// Deserialize into a new entity
|
||||||
|
let restored = deserialize_npc_from_frozen(&frozen, &mut world);
|
||||||
|
|
||||||
|
// Verify StableEntityId matches
|
||||||
|
let orig_stable = world.get::<StableEntityId>(original).unwrap().0;
|
||||||
|
let rest_stable = world.get::<StableEntityId>(restored).unwrap().0;
|
||||||
|
assert_eq!(orig_stable, rest_stable, "StableId must match");
|
||||||
|
|
||||||
|
// Position
|
||||||
|
let orig_pos = world.get::<TilePosition>(original).copied().unwrap();
|
||||||
|
let rest_pos = world.get::<TilePosition>(restored).copied().unwrap();
|
||||||
|
assert_eq!(orig_pos, rest_pos, "position must match");
|
||||||
|
|
||||||
|
// Tolerance
|
||||||
|
let orig_tol = world.get::<ToleranceThreshold>(original).cloned().unwrap();
|
||||||
|
let rest_tol = world.get::<ToleranceThreshold>(restored).cloned().unwrap();
|
||||||
|
assert_eq!(orig_tol.current_stress, rest_tol.current_stress);
|
||||||
|
assert_eq!(orig_tol.threshold, rest_tol.threshold);
|
||||||
|
|
||||||
|
// Contentment
|
||||||
|
let orig_con = world.get::<Contentment>(original).cloned().unwrap();
|
||||||
|
let rest_con = world.get::<Contentment>(restored).cloned().unwrap();
|
||||||
|
assert_eq!(orig_con.level, rest_con.level, "contentment must match");
|
||||||
|
|
||||||
|
// Want
|
||||||
|
let orig_want = world.get::<Want>(original).cloned().unwrap();
|
||||||
|
let rest_want = world.get::<Want>(restored).cloned().unwrap();
|
||||||
|
assert_eq!(orig_want.primary, rest_want.primary, "want.primary must match");
|
||||||
|
assert_eq!(orig_want.intensity, rest_want.intensity, "want.intensity must match");
|
||||||
|
|
||||||
|
// Secret severity
|
||||||
|
let orig_secret = world.get::<Secret>(original).cloned().unwrap();
|
||||||
|
let rest_secret = world.get::<Secret>(restored).cloned().unwrap();
|
||||||
|
assert_eq!(orig_secret.severity, rest_secret.severity, "secret severity must match");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_npc_without_optional_axes_uses_safe_defaults() {
|
||||||
|
// Spec (#96): optional fields absent in frozen state produce sensible defaults.
|
||||||
|
use crate::npc::SecretSeverity;
|
||||||
|
let frozen = NpcSaveState {
|
||||||
|
stable_id: StableId(999),
|
||||||
|
position: TilePosition::new(0, 0, 0),
|
||||||
|
secret_severity: SecretSeverity::Moderate,
|
||||||
|
relationships: None,
|
||||||
|
current_stress: 10,
|
||||||
|
tolerance_threshold: 50,
|
||||||
|
contentment: 0,
|
||||||
|
knowledge_graph: None,
|
||||||
|
want: None,
|
||||||
|
secret: None,
|
||||||
|
routine: None,
|
||||||
|
information_inventory: None,
|
||||||
|
personality_traits: None,
|
||||||
|
tell_system: None,
|
||||||
|
skill_set: None,
|
||||||
|
combat_capability: None,
|
||||||
|
mood_state: None,
|
||||||
|
job_performance: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut world = World::new();
|
||||||
|
let entity = deserialize_npc_from_frozen(&frozen, &mut world);
|
||||||
|
|
||||||
|
// Entity must exist with required components
|
||||||
|
assert!(world.get::<Npc>(entity).is_some());
|
||||||
|
assert!(world.get::<StableEntityId>(entity).is_some());
|
||||||
|
assert!(world.get::<ToleranceThreshold>(entity).is_some());
|
||||||
|
assert!(world.get::<Contentment>(entity).is_some());
|
||||||
|
assert!(world.get::<Want>(entity).is_some(), "Want defaults to Safety");
|
||||||
|
assert!(world.get::<Secret>(entity).is_some(), "Secret built from secret_severity");
|
||||||
|
|
||||||
|
// Secret severity must be preserved from the legacy field
|
||||||
|
let secret = world.get::<Secret>(entity).unwrap();
|
||||||
|
assert_eq!(secret.severity, SecretSeverity::Moderate);
|
||||||
|
|
||||||
|
// Optional axes absent in frozen state → not inserted or use defaults
|
||||||
|
assert!(world.get::<DailyRoutine>(entity).is_none(), "routine absent when not frozen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frozen_npc_roundtrips_via_messagepack() {
|
||||||
|
// Spec (#96): NpcSaveState must survive MessagePack roundtrip.
|
||||||
|
let mut world = World::new();
|
||||||
|
let entity = spawn_minimal_npc(&mut world, StableId(55));
|
||||||
|
let frozen = serialize_npc_to_frozen(entity, &world);
|
||||||
|
|
||||||
|
// Wrap in SaveStateV1 for MessagePack encoding
|
||||||
|
let save = SaveStateV1 {
|
||||||
|
format_version: SAVE_FORMAT_VERSION,
|
||||||
|
tick: 100,
|
||||||
|
tick_rate: TickRate::Full,
|
||||||
|
seed: 12,
|
||||||
|
player_knowledge: KnowledgeGraph::new(),
|
||||||
|
relationship_graph: RelationshipGraph::new(),
|
||||||
|
npc_states: vec![frozen],
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = save.to_bytes().expect("serialize");
|
||||||
|
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||||
|
let bytes2 = recovered.to_bytes().expect("re-serialize");
|
||||||
|
assert_eq!(bytes, bytes2, "frozen NPC state must roundtrip via MessagePack");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_npc_panics_without_stable_entity_id() {
|
||||||
|
// Spec (#96): StableEntityId is required — missing it is a programmer error.
|
||||||
|
let mut world = World::new();
|
||||||
|
let entity = world.spawn((Npc, TilePosition::new(0, 0, 0))).id();
|
||||||
|
|
||||||
|
// World doesn't implement UnwindSafe — wrap in AssertUnwindSafe.
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
serialize_npc_to_frozen(entity, &world);
|
||||||
|
}));
|
||||||
|
assert!(result.is_err(), "must panic without StableEntityId");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync {
|
|||||||
/// Return all entities at the exact `position`.
|
/// Return all entities at the exact `position`.
|
||||||
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
|
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
|
||||||
|
|
||||||
|
/// Return all entities within Manhattan distance `radius` of `position`,
|
||||||
|
/// **including** entities exactly at `position`. Single-pass alternative to
|
||||||
|
/// `entities_in_range` + `entities_at`.
|
||||||
|
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
|
||||||
|
let mut result = self.entities_in_range(position, radius);
|
||||||
|
result.extend(self.entities_at(position));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert or update an entity's position in the index.
|
/// Insert or update an entity's position in the index.
|
||||||
fn update(&mut self, entity: Entity, position: TilePosition);
|
fn update(&mut self, entity: Entity, position: TilePosition);
|
||||||
|
|
||||||
@@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, pos)| {
|
||||||
|
pos == position
|
||||||
|
|| pos
|
||||||
|
.manhattan_distance(position)
|
||||||
|
.is_some_and(|d| d <= radius)
|
||||||
|
})
|
||||||
|
.map(|(entity, _)| *entity)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn update(&mut self, entity: Entity, position: TilePosition) {
|
fn update(&mut self, entity: Entity, position: TilePosition) {
|
||||||
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
|
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
|
||||||
entry.1 = position;
|
entry.1 = position;
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
// Simulation tier system
|
// Simulation tier system
|
||||||
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
|
// Implements D-026: Active/Background/State-saved/Ungenerated tiers
|
||||||
// Tier transitions based on player approach distance (#99).
|
// Tier transitions based on player approach distance (#99).
|
||||||
|
// Scope tag system: NPCs with active scope tags stay pinned to ActiveSim (#98).
|
||||||
|
// Timestamp-based eviction: LRU eviction when ActiveSim exceeds capacity (#97).
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, BinaryHeap};
|
||||||
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
use bevy_app::prelude::*;
|
use bevy_app::prelude::*;
|
||||||
use bevy_ecs::prelude::*;
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::knowledge::graph::KnowledgeGraph;
|
||||||
|
use crate::knowledge::registry::StableEntityId;
|
||||||
|
use crate::knowledge::types::KnowledgeConfidence;
|
||||||
|
use crate::npc::{Npc, RelationshipKind};
|
||||||
|
use crate::npc::relationships::RelationshipGraph;
|
||||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||||
|
|
||||||
// --- Tier radius constants (D-026) ---
|
// --- Tier radius constants (D-026) ---
|
||||||
@@ -38,20 +50,365 @@ pub struct BackgroundSim;
|
|||||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||||
pub struct StateSaved;
|
pub struct StateSaved;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eviction system (D-026, #97)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maximum number of entities in `ActiveSim` before LRU eviction kicks in (D-026).
|
||||||
|
pub const ACTIVE_SIM_CAPACITY: usize = 80;
|
||||||
|
|
||||||
|
/// Tracks the tick at which the player last interacted with or observed an NPC (#97).
|
||||||
|
/// Updated by `update_last_interaction_tick` when an NPC is in the player's LOS.
|
||||||
|
/// Used by `evict_excess_active` as the LRU sort key.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||||
|
pub struct LastInteractionTick(pub u64);
|
||||||
|
|
||||||
|
/// Tracks current `ActiveSim` entity count vs. capacity (#97, D-026).
|
||||||
|
/// Updated each tick by `evict_excess_active`.
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct SimSpacePressure {
|
||||||
|
/// Number of entities in `ActiveSim` at the start of the current tick's eviction pass.
|
||||||
|
///
|
||||||
|
/// Set by `evict_excess_active` *before* any evictions run. Eviction commands are
|
||||||
|
/// deferred (applied after the system), so `active_count` reflects the pre-eviction
|
||||||
|
/// count, not the post-eviction count. Consumers (e.g., HUD pressure display) should
|
||||||
|
/// treat this as the high-water mark for the tick.
|
||||||
|
pub active_count: usize,
|
||||||
|
/// Capacity ceiling.
|
||||||
|
pub capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SimSpacePressure {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: ACTIVE_SIM_CAPACITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scope tag system (D-026, #98)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Scope tag kinds: reasons why an NPC stays pinned to `ActiveSim` (D-026).
|
||||||
|
///
|
||||||
|
/// Four variants track distinct reasons for pinning. An NPC may have multiple
|
||||||
|
/// reasons simultaneously — all are tracked in `ScopeTag`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ScopeTagKind {
|
||||||
|
/// NPC is in the player's immediate neighborhood.
|
||||||
|
/// Set at session start for NPCs within `ACTIVE_RADIUS`. Managed by
|
||||||
|
/// `assign_neighborhood_tags_on_start` (deferred: future sprint).
|
||||||
|
Neighborhood,
|
||||||
|
/// NPC is involved in an active quest.
|
||||||
|
/// Reserved for the quest system (deferred: future sprint).
|
||||||
|
ActiveQuest,
|
||||||
|
/// NPC has a `Friend` or `Colleague` relationship with the player character.
|
||||||
|
/// Assigned by `assign_scope_tags` each tick from `RelationshipGraph`.
|
||||||
|
Colleague,
|
||||||
|
/// NPC is known to the player with confidence >= `KnowsOf`.
|
||||||
|
/// Assigned by `assign_scope_tags` each tick from player `KnowledgeGraph`.
|
||||||
|
KnownContact,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scope tag component: which scope tags currently apply to this NPC (D-026).
|
||||||
|
///
|
||||||
|
/// NPCs carrying at least one scope tag are kept in `ActiveSim` regardless of
|
||||||
|
/// distance or LRU eviction pressure. `ScopePinned` is the eviction guard;
|
||||||
|
/// this component is the source of truth.
|
||||||
|
///
|
||||||
|
/// Assignment:
|
||||||
|
/// - `KnownContact` and `Colleague`: recomputed by `assign_scope_tags` each tick.
|
||||||
|
/// - `Neighborhood`: set at session start (see `ScopeTagKind::Neighborhood`).
|
||||||
|
/// - `ActiveQuest`: reserved for future quest system.
|
||||||
|
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ScopeTag {
|
||||||
|
pub tags: BTreeSet<ScopeTagKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeTag {
|
||||||
|
/// Create a `ScopeTag` with a single initial kind.
|
||||||
|
pub fn with(kind: ScopeTagKind) -> Self {
|
||||||
|
let mut tags = BTreeSet::new();
|
||||||
|
tags.insert(kind);
|
||||||
|
Self { tags }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a scope tag kind.
|
||||||
|
pub fn add(&mut self, kind: ScopeTagKind) {
|
||||||
|
self.tags.insert(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a scope tag kind.
|
||||||
|
pub fn remove(&mut self, kind: ScopeTagKind) {
|
||||||
|
self.tags.remove(&kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this NPC carries at least one scope tag.
|
||||||
|
pub fn is_pinned(&self) -> bool {
|
||||||
|
!self.tags.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this specific kind is present.
|
||||||
|
pub fn contains(&self, kind: ScopeTagKind) -> bool {
|
||||||
|
self.tags.contains(&kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker component: this NPC is scope-pinned — the eviction system must skip it.
|
||||||
|
///
|
||||||
|
/// Kept in sync with `ScopeTag` by `sync_scope_pins`. Always use `ScopeTag`
|
||||||
|
/// as the source of truth; treat `ScopePinned` as a query-optimisation cache.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ScopePinned;
|
||||||
|
|
||||||
/// Plugin registering the tier marker components and the tier transition system.
|
/// Plugin registering the tier marker components and the tier transition system.
|
||||||
pub struct TierPlugin;
|
pub struct TierPlugin;
|
||||||
|
|
||||||
impl Plugin for TierPlugin {
|
impl Plugin for TierPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<SimSpacePressure>();
|
||||||
|
|
||||||
// Tier transition runs after movement so positions are current.
|
// Tier transition runs after movement so positions are current.
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
update_tier_markers.after(crate::simulation::movement::validate_movement),
|
update_tier_markers.after(crate::simulation::movement::validate_movement),
|
||||||
);
|
);
|
||||||
|
// Scope tag assignment runs each tick to keep KnownContact / Colleague current.
|
||||||
|
// Must run before sync_scope_pins so pins are correct before eviction checks.
|
||||||
|
// Eviction runs after scope pins are synced (respects ScopePinned).
|
||||||
|
// LastInteractionTick update runs after visibility geometry.
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
assign_scope_tags,
|
||||||
|
sync_scope_pins.after(assign_scope_tags),
|
||||||
|
update_last_interaction_tick
|
||||||
|
.after(crate::perception::observer::compute_visibility_geometry),
|
||||||
|
evict_excess_active
|
||||||
|
.after(sync_scope_pins)
|
||||||
|
.after(update_tier_markers),
|
||||||
|
),
|
||||||
|
);
|
||||||
tracing::debug!("TierPlugin initialized");
|
tracing::debug!("TierPlugin initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scope tag systems (D-026, #98)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// System: assign `KnownContact` and `Colleague` scope tags from player epistemics.
|
||||||
|
///
|
||||||
|
/// Runs each tick. Clears and recomputes `KnownContact` and `Colleague` tags for all
|
||||||
|
/// NPCs based on:
|
||||||
|
/// - `KnownContact`: player `KnowledgeGraph` has an entry for this NPC with
|
||||||
|
/// confidence >= `KnowsOf`.
|
||||||
|
/// - `Colleague`: global `RelationshipGraph` has an edge from the player to this NPC
|
||||||
|
/// with kind `Friend` or `Colleague`.
|
||||||
|
///
|
||||||
|
/// `Neighborhood` and `ActiveQuest` tags are NOT touched by this system:
|
||||||
|
/// - `Neighborhood` is set at session start and persists (future sprint).
|
||||||
|
/// - `ActiveQuest` is reserved for the quest system (future sprint).
|
||||||
|
///
|
||||||
|
/// No-op when there is no `PlayerCharacter` entity.
|
||||||
|
pub fn assign_scope_tags(
|
||||||
|
player_query: Query<(&KnowledgeGraph, &StableEntityId), With<PlayerCharacter>>,
|
||||||
|
rel_graph: Res<RelationshipGraph>,
|
||||||
|
mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With<Npc>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let Ok((player_kg, player_stable)) = player_query.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let player_id = player_stable.0;
|
||||||
|
|
||||||
|
// Collect KnownContact set: entities in player KG with confidence >= KnowsOf.
|
||||||
|
// BTreeSet for deterministic iteration (D-010).
|
||||||
|
let known_contacts: BTreeSet<_> = player_kg
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, ek)| ek.confidence >= KnowledgeConfidence::KnowsOf)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Collect Colleague set: player → NPC relationship edges with Friend/Colleague kind.
|
||||||
|
let colleagues: BTreeSet<_> = rel_graph
|
||||||
|
.relationships_of(&player_id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, edge)| {
|
||||||
|
matches!(edge.kind, RelationshipKind::Friend | RelationshipKind::Colleague)
|
||||||
|
})
|
||||||
|
.map(|(target_id, _)| *target_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (entity, npc_stable, maybe_scope_tag) in &mut npcs {
|
||||||
|
let npc_id = npc_stable.0;
|
||||||
|
let is_known = known_contacts.contains(&npc_id);
|
||||||
|
let is_colleague = colleagues.contains(&npc_id);
|
||||||
|
|
||||||
|
match maybe_scope_tag {
|
||||||
|
Some(mut scope_tag) => {
|
||||||
|
// Remove computed tags, then re-add if still applicable.
|
||||||
|
scope_tag.remove(ScopeTagKind::KnownContact);
|
||||||
|
scope_tag.remove(ScopeTagKind::Colleague);
|
||||||
|
if is_known {
|
||||||
|
scope_tag.add(ScopeTagKind::KnownContact);
|
||||||
|
}
|
||||||
|
if is_colleague {
|
||||||
|
scope_tag.add(ScopeTagKind::Colleague);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if is_known || is_colleague => {
|
||||||
|
// Create a new ScopeTag component for this NPC.
|
||||||
|
let mut scope_tag = ScopeTag::default();
|
||||||
|
if is_known {
|
||||||
|
scope_tag.add(ScopeTagKind::KnownContact);
|
||||||
|
}
|
||||||
|
if is_colleague {
|
||||||
|
scope_tag.add(ScopeTagKind::Colleague);
|
||||||
|
}
|
||||||
|
commands.entity(entity).insert(scope_tag);
|
||||||
|
}
|
||||||
|
None => {} // NPC not known or related — no scope tag needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System: keep `ScopePinned` markers in sync with `ScopeTag` components.
|
||||||
|
///
|
||||||
|
/// Runs after `assign_scope_tags`. For each NPC:
|
||||||
|
/// - `ScopeTag` present and non-empty → add `ScopePinned` (if not already present).
|
||||||
|
/// - `ScopeTag` absent or empty → remove `ScopePinned` (if present).
|
||||||
|
///
|
||||||
|
/// The eviction system (#97) queries `Without<ScopePinned>` to skip pinned NPCs.
|
||||||
|
pub fn sync_scope_pins(
|
||||||
|
mut commands: Commands,
|
||||||
|
needs_pin: Query<(Entity, &ScopeTag), Without<ScopePinned>>,
|
||||||
|
may_need_unpin: Query<(Entity, Option<&ScopeTag>), With<ScopePinned>>,
|
||||||
|
) {
|
||||||
|
// Add ScopePinned to NPCs that have a non-empty ScopeTag.
|
||||||
|
for (entity, scope_tag) in &needs_pin {
|
||||||
|
if scope_tag.is_pinned() {
|
||||||
|
commands.entity(entity).insert(ScopePinned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ScopePinned from NPCs whose ScopeTag is absent or empty.
|
||||||
|
for (entity, maybe_scope_tag) in &may_need_unpin {
|
||||||
|
let still_pinned = maybe_scope_tag.map(|s| s.is_pinned()).unwrap_or(false);
|
||||||
|
if !still_pinned {
|
||||||
|
commands.entity(entity).remove::<ScopePinned>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eviction systems (D-026, #97)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// System: update `LastInteractionTick` for NPCs visible to the player (#97).
|
||||||
|
///
|
||||||
|
/// Runs after visibility geometry is computed. Any NPC at a visible position
|
||||||
|
/// (in the player's LOS) gets its `LastInteractionTick` set to the current tick.
|
||||||
|
/// NPCs without this component get it inserted on first observation.
|
||||||
|
pub fn update_last_interaction_tick(
|
||||||
|
time: Res<crate::simulation::time::SimulationTime>,
|
||||||
|
vis_geo: Res<crate::perception::query::VisibilityGeometry>,
|
||||||
|
mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With<Npc>>,
|
||||||
|
npcs_without_tick: Query<(Entity, &TilePosition), (With<Npc>, Without<LastInteractionTick>)>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let current_tick = time.tick;
|
||||||
|
|
||||||
|
// Update existing LastInteractionTick for visible NPCs.
|
||||||
|
for (pos, mut last_tick) in &mut npcs_with_tick {
|
||||||
|
if pos.z == vis_geo.observer_z
|
||||||
|
&& vis_geo.visible_positions.contains(&(pos.x, pos.y))
|
||||||
|
{
|
||||||
|
last_tick.0 = current_tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert LastInteractionTick for NPCs that don't have it yet but are visible.
|
||||||
|
for (entity, pos) in &npcs_without_tick {
|
||||||
|
if pos.z == vis_geo.observer_z
|
||||||
|
&& vis_geo.visible_positions.contains(&(pos.x, pos.y))
|
||||||
|
{
|
||||||
|
commands.entity(entity).insert(LastInteractionTick(current_tick));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System: evict excess `ActiveSim` entities when count exceeds capacity (#97).
|
||||||
|
///
|
||||||
|
/// When more than `ACTIVE_SIM_CAPACITY` entities are in `ActiveSim`:
|
||||||
|
/// 1. Skip all `ScopePinned` entities (they stay Active regardless).
|
||||||
|
/// 2. Sort remaining by `LastInteractionTick` (oldest first) via min-heap.
|
||||||
|
/// 3. Demote the oldest N entities to `BackgroundSim` (or `StateSaved` if beyond
|
||||||
|
/// background radius).
|
||||||
|
///
|
||||||
|
/// Updates `SimSpacePressure` resource with current counts.
|
||||||
|
pub fn evict_excess_active(
|
||||||
|
mut commands: Commands,
|
||||||
|
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||||
|
active_npcs: Query<
|
||||||
|
(Entity, &TilePosition, Option<&LastInteractionTick>),
|
||||||
|
(With<ActiveSim>, With<Npc>, Without<ScopePinned>),
|
||||||
|
>,
|
||||||
|
active_count_query: Query<(), With<ActiveSim>>,
|
||||||
|
mut pressure: ResMut<SimSpacePressure>,
|
||||||
|
) {
|
||||||
|
let total_active = active_count_query.iter().count();
|
||||||
|
pressure.active_count = total_active;
|
||||||
|
|
||||||
|
if total_active <= pressure.capacity {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let excess = total_active - pressure.capacity;
|
||||||
|
let Ok(player_pos) = player_query.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Min-heap keyed by LastInteractionTick (oldest = smallest = evicted first).
|
||||||
|
// Entities without LastInteractionTick get tick 0 (most stale).
|
||||||
|
// NOTE: Ties in tick value are broken by Entity index, which is non-deterministic
|
||||||
|
// across runs (bevy Entity allocation order). For v0.1 this is acceptable —
|
||||||
|
// deterministic replay (D-010 principle 4) replays inputs, not eviction order.
|
||||||
|
// If eviction order must be deterministic, key by (tick, StableId) instead.
|
||||||
|
let mut heap: BinaryHeap<Reverse<(u64, Entity, TilePosition)>> = BinaryHeap::new();
|
||||||
|
for (entity, pos, maybe_tick) in &active_npcs {
|
||||||
|
let tick = maybe_tick.map(|t| t.0).unwrap_or(0);
|
||||||
|
heap.push(Reverse((tick, entity, *pos)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut evicted = 0;
|
||||||
|
while evicted < excess {
|
||||||
|
let Some(Reverse((_, entity, pos))) = heap.pop() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dist = tile_distance(player_pos, &pos);
|
||||||
|
if dist > BACKGROUND_RADIUS {
|
||||||
|
commands.entity(entity).remove::<ActiveSim>().insert(StateSaved);
|
||||||
|
} else {
|
||||||
|
commands.entity(entity).remove::<ActiveSim>().insert(BackgroundSim);
|
||||||
|
}
|
||||||
|
evicted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if evicted > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
"evicted {} excess ActiveSim entities (was {}, cap {})",
|
||||||
|
evicted,
|
||||||
|
total_active,
|
||||||
|
pressure.capacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tier transition system (D-026, #99) ---
|
// --- Tier transition system (D-026, #99) ---
|
||||||
|
|
||||||
/// Manhattan tile distance between two positions, returning `u32::MAX` for
|
/// Manhattan tile distance between two positions, returning `u32::MAX` for
|
||||||
@@ -432,4 +789,541 @@ mod tests {
|
|||||||
assert!(world.get::<ActiveSim>(npc).is_none(), "demoted to Background");
|
assert!(world.get::<ActiveSim>(npc).is_none(), "demoted to Background");
|
||||||
assert!(world.get::<BackgroundSim>(npc).is_some());
|
assert!(world.get::<BackgroundSim>(npc).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ScopeTag component tests (#98, D-026)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_tag_with_creates_single_kind() {
|
||||||
|
let tag = ScopeTag::with(ScopeTagKind::KnownContact);
|
||||||
|
assert!(tag.contains(ScopeTagKind::KnownContact));
|
||||||
|
assert!(!tag.contains(ScopeTagKind::Colleague));
|
||||||
|
assert!(tag.is_pinned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_tag_add_and_remove() {
|
||||||
|
let mut tag = ScopeTag::default();
|
||||||
|
assert!(!tag.is_pinned(), "new ScopeTag is empty");
|
||||||
|
|
||||||
|
tag.add(ScopeTagKind::Colleague);
|
||||||
|
assert!(tag.is_pinned());
|
||||||
|
assert!(tag.contains(ScopeTagKind::Colleague));
|
||||||
|
|
||||||
|
tag.add(ScopeTagKind::KnownContact);
|
||||||
|
assert!(tag.contains(ScopeTagKind::KnownContact));
|
||||||
|
|
||||||
|
tag.remove(ScopeTagKind::Colleague);
|
||||||
|
assert!(!tag.contains(ScopeTagKind::Colleague));
|
||||||
|
assert!(tag.is_pinned(), "still pinned by KnownContact");
|
||||||
|
|
||||||
|
tag.remove(ScopeTagKind::KnownContact);
|
||||||
|
assert!(!tag.is_pinned(), "unpinned when all tags removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_tag_multiple_kinds_coexist() {
|
||||||
|
let mut tag = ScopeTag::default();
|
||||||
|
tag.add(ScopeTagKind::Neighborhood);
|
||||||
|
tag.add(ScopeTagKind::ActiveQuest);
|
||||||
|
tag.add(ScopeTagKind::Colleague);
|
||||||
|
tag.add(ScopeTagKind::KnownContact);
|
||||||
|
|
||||||
|
assert_eq!(tag.tags.len(), 4, "all four kinds present");
|
||||||
|
assert!(tag.is_pinned());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// sync_scope_pins system tests (#98)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_sync_scope_pins(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(sync_scope_pins);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_pins_adds_scope_pinned_for_non_empty_tag() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ScopeTag::with(ScopeTagKind::KnownContact)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_sync_scope_pins(&mut world);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopePinned>(npc).is_some(),
|
||||||
|
"ScopePinned added for non-empty ScopeTag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_pins_does_not_add_for_empty_tag() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let npc = world.spawn((Npc, ScopeTag::default())).id();
|
||||||
|
|
||||||
|
run_sync_scope_pins(&mut world);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopePinned>(npc).is_none(),
|
||||||
|
"ScopePinned must NOT be added for empty ScopeTag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_pins_removes_scope_pinned_when_tag_emptied() {
|
||||||
|
let mut world = World::new();
|
||||||
|
// Start with ScopePinned already set but ScopeTag now empty.
|
||||||
|
let npc = world.spawn((Npc, ScopePinned, ScopeTag::default())).id();
|
||||||
|
|
||||||
|
run_sync_scope_pins(&mut world);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopePinned>(npc).is_none(),
|
||||||
|
"ScopePinned removed when ScopeTag is empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_pins_removes_scope_pinned_when_tag_absent() {
|
||||||
|
let mut world = World::new();
|
||||||
|
// NPC has ScopePinned but no ScopeTag component at all.
|
||||||
|
let npc = world.spawn((Npc, ScopePinned)).id();
|
||||||
|
|
||||||
|
run_sync_scope_pins(&mut world);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopePinned>(npc).is_none(),
|
||||||
|
"ScopePinned removed when ScopeTag absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_pins_keeps_existing_scope_pinned() {
|
||||||
|
// An NPC that already has ScopePinned AND a non-empty ScopeTag should remain pinned.
|
||||||
|
let mut world = World::new();
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, ScopePinned, ScopeTag::with(ScopeTagKind::Colleague)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_sync_scope_pins(&mut world);
|
||||||
|
|
||||||
|
// After sync, the NPC should still have ScopePinned (it was already there
|
||||||
|
// AND the scope tag is non-empty — so no change needed).
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopePinned>(npc).is_some(),
|
||||||
|
"ScopePinned preserved for non-empty ScopeTag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// assign_scope_tags system tests (#98)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_assign_scope_tags(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(assign_scope_tags);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_scope_tags_no_op_without_player() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<RelationshipGraph>();
|
||||||
|
|
||||||
|
// NPC exists but no PlayerCharacter
|
||||||
|
let npc = world.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1)))).id();
|
||||||
|
|
||||||
|
run_assign_scope_tags(&mut world);
|
||||||
|
|
||||||
|
// No ScopeTag should be assigned — no player
|
||||||
|
assert!(world.get::<ScopeTag>(npc).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_scope_tags_known_contact_from_player_kg() {
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<RelationshipGraph>();
|
||||||
|
|
||||||
|
let npc_stable = StableId(10);
|
||||||
|
let player_stable = StableId(1);
|
||||||
|
|
||||||
|
// Set up player with a KnowledgeGraph that knows the NPC at KnowsOf level.
|
||||||
|
let mut player_kg = KnowledgeGraph::new();
|
||||||
|
player_kg.observe_entity(npc_stable, make_pos(5, 5), 0);
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
make_pos(0, 0),
|
||||||
|
player_kg,
|
||||||
|
StableEntityId(player_stable),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Spawn the NPC
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, make_pos(10, 0), StableEntityId(npc_stable)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_assign_scope_tags(&mut world);
|
||||||
|
|
||||||
|
let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag should be assigned");
|
||||||
|
assert!(
|
||||||
|
scope_tag.contains(ScopeTagKind::KnownContact),
|
||||||
|
"NPC known at KnowsOf level should get KnownContact tag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_scope_tags_colleague_from_relationship_graph() {
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
use crate::npc::relationships::RelationshipEdge;
|
||||||
|
|
||||||
|
let mut world = World::new();
|
||||||
|
|
||||||
|
let npc_stable = StableId(20);
|
||||||
|
let player_stable = StableId(1);
|
||||||
|
|
||||||
|
// Player KG is empty — no KnownContact.
|
||||||
|
let player_kg = KnowledgeGraph::new();
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
make_pos(0, 0),
|
||||||
|
player_kg,
|
||||||
|
StableEntityId(player_stable),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Set up RelationshipGraph with player → NPC as Friend.
|
||||||
|
let mut rel_graph = RelationshipGraph::new();
|
||||||
|
rel_graph.set_relationship(
|
||||||
|
player_stable,
|
||||||
|
npc_stable,
|
||||||
|
RelationshipEdge {
|
||||||
|
kind: RelationshipKind::Friend,
|
||||||
|
trust: 5,
|
||||||
|
history: vec![],
|
||||||
|
last_interaction_tick: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
world.insert_resource(rel_graph);
|
||||||
|
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, make_pos(0, 5), StableEntityId(npc_stable)))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_assign_scope_tags(&mut world);
|
||||||
|
|
||||||
|
let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag assigned for colleague");
|
||||||
|
assert!(
|
||||||
|
scope_tag.contains(ScopeTagKind::Colleague),
|
||||||
|
"Friend relationship should grant Colleague scope tag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_scope_tags_does_not_affect_unknown_npcs() {
|
||||||
|
use crate::knowledge::types::StableId;
|
||||||
|
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<RelationshipGraph>();
|
||||||
|
|
||||||
|
let player_stable = StableId(1);
|
||||||
|
let player_kg = KnowledgeGraph::new(); // empty — knows nobody
|
||||||
|
|
||||||
|
world.spawn((
|
||||||
|
PlayerCharacter,
|
||||||
|
make_pos(0, 0),
|
||||||
|
player_kg,
|
||||||
|
StableEntityId(player_stable),
|
||||||
|
));
|
||||||
|
|
||||||
|
// NPC that the player doesn't know
|
||||||
|
let npc = world
|
||||||
|
.spawn((Npc, make_pos(10, 0), StableEntityId(StableId(99))))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
run_assign_scope_tags(&mut world);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
world.get::<ScopeTag>(npc).is_none(),
|
||||||
|
"unknown NPC should not receive ScopeTag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_pinned_npc_in_query_without_scope_pinned_marker() {
|
||||||
|
// Verify that ScopePinned is a separate marker and Without<ScopePinned>
|
||||||
|
// correctly excludes pinned NPCs from eviction queries.
|
||||||
|
let mut world = World::new();
|
||||||
|
let pinned = world.spawn((Npc, ScopePinned)).id();
|
||||||
|
let unpinned = world.spawn(Npc).id();
|
||||||
|
|
||||||
|
let mut query = world.query_filtered::<Entity, (With<Npc>, Without<ScopePinned>)>();
|
||||||
|
let unpinned_results: Vec<Entity> = query.iter(&world).collect();
|
||||||
|
|
||||||
|
assert_eq!(unpinned_results.len(), 1, "only one unpinned NPC");
|
||||||
|
assert_eq!(unpinned_results[0], unpinned);
|
||||||
|
assert!(!unpinned_results.contains(&pinned), "pinned NPC excluded from eviction query");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Eviction system tests (#97, D-026)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_evict_excess_active(world: &mut World) {
|
||||||
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||||
|
schedule.add_systems(evict_excess_active);
|
||||||
|
schedule.run(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_eviction_when_under_capacity() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 5,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// Spawn 3 active NPCs (under cap of 5)
|
||||||
|
let npc1 = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id();
|
||||||
|
let npc2 = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id();
|
||||||
|
let npc3 = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id();
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
// All should remain Active
|
||||||
|
assert!(world.get::<ActiveSim>(npc1).is_some());
|
||||||
|
assert!(world.get::<ActiveSim>(npc2).is_some());
|
||||||
|
assert!(world.get::<ActiveSim>(npc3).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evicts_oldest_when_over_capacity() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 2,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// 3 NPCs, cap=2 → must evict 1 (the oldest: tick 10)
|
||||||
|
let oldest = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id();
|
||||||
|
let mid = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id();
|
||||||
|
let newest = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id();
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
assert!(world.get::<ActiveSim>(oldest).is_none(), "oldest evicted");
|
||||||
|
assert!(world.get::<BackgroundSim>(oldest).is_some(), "oldest → Background");
|
||||||
|
assert!(world.get::<ActiveSim>(mid).is_some(), "mid stays Active");
|
||||||
|
assert!(world.get::<ActiveSim>(newest).is_some(), "newest stays Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_skips_scope_pinned() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 1,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// 2 NPCs, cap=1. The oldest is ScopePinned → skip it, evict the other.
|
||||||
|
let pinned = world.spawn((
|
||||||
|
Npc, ActiveSim, ScopePinned,
|
||||||
|
ScopeTag::with(ScopeTagKind::KnownContact),
|
||||||
|
make_pos(5, 0), LastInteractionTick(5),
|
||||||
|
)).id();
|
||||||
|
let unpinned = world.spawn((
|
||||||
|
Npc, ActiveSim,
|
||||||
|
make_pos(6, 0), LastInteractionTick(20),
|
||||||
|
)).id();
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
assert!(world.get::<ActiveSim>(pinned).is_some(), "pinned NPC stays Active");
|
||||||
|
assert!(world.get::<ActiveSim>(unpinned).is_none(), "unpinned NPC evicted");
|
||||||
|
assert!(world.get::<BackgroundSim>(unpinned).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_demotes_to_state_saved_if_beyond_background_radius() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 1,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// NPC at distance 200 (beyond BACKGROUND_RADIUS=120) → StateSaved
|
||||||
|
let far = world.spawn((
|
||||||
|
Npc, ActiveSim,
|
||||||
|
make_pos(200, 0), LastInteractionTick(5),
|
||||||
|
)).id();
|
||||||
|
// NPC at distance 5 (within ACTIVE_RADIUS) → stays
|
||||||
|
let near = world.spawn((
|
||||||
|
Npc, ActiveSim,
|
||||||
|
make_pos(5, 0), LastInteractionTick(50),
|
||||||
|
)).id();
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
assert!(world.get::<ActiveSim>(far).is_none(), "far NPC evicted");
|
||||||
|
assert!(world.get::<StateSaved>(far).is_some(), "far NPC → StateSaved");
|
||||||
|
assert!(world.get::<ActiveSim>(near).is_some(), "near NPC stays Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_handles_npcs_without_last_interaction_tick() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 1,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// NPC without LastInteractionTick defaults to tick 0 (most stale)
|
||||||
|
let no_tick = world.spawn((Npc, ActiveSim, make_pos(5, 0))).id();
|
||||||
|
let with_tick = world.spawn((
|
||||||
|
Npc, ActiveSim,
|
||||||
|
make_pos(6, 0), LastInteractionTick(100),
|
||||||
|
)).id();
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
assert!(world.get::<ActiveSim>(no_tick).is_none(), "no-tick NPC evicted first");
|
||||||
|
assert!(world.get::<BackgroundSim>(no_tick).is_some());
|
||||||
|
assert!(world.get::<ActiveSim>(with_tick).is_some(), "with-tick NPC stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sim_space_pressure_updated_after_eviction() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 2,
|
||||||
|
});
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10)));
|
||||||
|
world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20)));
|
||||||
|
world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30)));
|
||||||
|
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
let pressure = world.resource::<SimSpacePressure>();
|
||||||
|
// active_count is set BEFORE eviction runs (it reads the pre-eviction count).
|
||||||
|
// The actual count changes via deferred commands, which apply after the system.
|
||||||
|
assert_eq!(pressure.active_count, 3, "pressure tracks pre-eviction count");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_pinned_npcs_survive_eviction_at_scale() {
|
||||||
|
// Regression: evict_excess_active must never demote a ScopePinned NPC,
|
||||||
|
// even when many NPCs are over capacity (D-026, #97, #98).
|
||||||
|
//
|
||||||
|
// Setup: 85 Active NPCs (capacity = 80 → 5 must be evicted).
|
||||||
|
// - 10 are ScopePinned (must ALL remain ActiveSim after eviction).
|
||||||
|
// - 75 are unpinned (5 oldest are eviction targets; 70 survive).
|
||||||
|
//
|
||||||
|
// The Without<ScopePinned> query filter in evict_excess_active is the
|
||||||
|
// core invariant under test. This test fails immediately if that filter
|
||||||
|
// is removed or mis-applied.
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(SimSpacePressure {
|
||||||
|
active_count: 0,
|
||||||
|
capacity: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player at origin — all NPCs are within BACKGROUND_RADIUS.
|
||||||
|
world.spawn((PlayerCharacter, make_pos(0, 0)));
|
||||||
|
|
||||||
|
// Spawn 10 ScopePinned NPCs. Give them the oldest ticks so they would
|
||||||
|
// be prime eviction candidates if Without<ScopePinned> were absent.
|
||||||
|
let pinned: Vec<Entity> = (0..10)
|
||||||
|
.map(|i| {
|
||||||
|
world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
ScopePinned,
|
||||||
|
make_pos(5 + i, 0),
|
||||||
|
LastInteractionTick(i as u64),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Spawn 5 unpinned NPCs with old ticks — these are the actual eviction targets.
|
||||||
|
let unpinned_oldest: Vec<Entity> = (0..5)
|
||||||
|
.map(|i| {
|
||||||
|
world
|
||||||
|
.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
make_pos(20 + i, 0),
|
||||||
|
LastInteractionTick(i as u64),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Spawn 70 unpinned NPCs with newer ticks — these survive.
|
||||||
|
for i in 0..70i32 {
|
||||||
|
world.spawn((
|
||||||
|
Npc,
|
||||||
|
ActiveSim,
|
||||||
|
make_pos(30 + i, 0),
|
||||||
|
LastInteractionTick(100 + i as u64),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total: 10 pinned + 5 oldest-unpinned + 70 newer-unpinned = 85 active.
|
||||||
|
// cap = 80 → exactly 5 must be evicted.
|
||||||
|
run_evict_excess_active(&mut world);
|
||||||
|
|
||||||
|
// Core invariant: ALL pinned entities remain ActiveSim.
|
||||||
|
for (i, &entity) in pinned.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
world.get::<ActiveSim>(entity).is_some(),
|
||||||
|
"ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
world.get::<BackgroundSim>(entity).is_none(),
|
||||||
|
"ScopePinned NPC {} must NOT be demoted to BackgroundSim",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
world.get::<StateSaved>(entity).is_none(),
|
||||||
|
"ScopePinned NPC {} must NOT be demoted to StateSaved",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the 5 oldest unpinned were the ones evicted.
|
||||||
|
let evicted_count = unpinned_oldest
|
||||||
|
.iter()
|
||||||
|
.filter(|&&e| world.get::<ActiveSim>(e).is_none())
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
evicted_count, 5,
|
||||||
|
"exactly 5 unpinned NPCs (the oldest) should have been evicted to reach capacity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// LastInteractionTick component tests (#97)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_interaction_tick_defaults_to_zero() {
|
||||||
|
let tick = LastInteractionTick::default();
|
||||||
|
assert_eq!(tick.0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user