Compare commits

...
104 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 32bf807308 chore(meta): release v0.1.19
Sprint 19: Persist — save/load, tier eviction/scope, test infra,
protocol handshake, IPC fixtures/benchmark. 15 tickets complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:56:32 +01:00
jpmschweitzer 30a5311f24 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-25 16:54:29 +01:00
jpmschweitzerandClaude Opus 4.6 3725a3df5e fix(client): address PR #70 review — event leak, quit flush, notification color
- input_mapper.gd: call set_input_as_handled() before early return on
  empty game_id so F5/F6 events don't propagate to other handlers
- session_manager.gd: defer scene change by one frame after buffering
  quit-save so SimBridge._process() flushes the outbound buffer
- monologue_display.gd: tag queued notifications with is_notification
  flag so drain path routes to _show_notification_line (correct color)
  instead of _show_line (lattice-profile fallback color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:43:53 +01:00
jpmschweitzerandClaude Opus 4.6 e1ea07e746 feat(client): save/load client UI — F5/F6 quicksave/quickload (#554)
Wire SaveGame/LoadGame player actions through the full client stack:
protocol v15 decode, InputMapper F5/F6 bindings, SimBridge wire mapping
with one-shot carry-forward, GameState save_result field, and HUD
notification via monologue display. Quit-to-menu triggers quicksave
before scene change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 704415fa99 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:11:33 +01:00
jpmschweitzerandClaude Opus 4.6 af3398ea34 fix(ci): address PR #69 review — stale comments, dead CLI flags
- gen_fixtures.rs: version comments now say PROTOCOL_VERSION instead
  of hardcoded 14, so they stay correct across bumps
- run-ipc-benchmark: remove --iterations flag that was parsed but
  never forwarded to the Rust test (compile-time constant governs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:10:23 +01:00
jpmschweitzer a0ed10468b Merge remote-tracking branch 'origin/ci' 2026-02-25 13:09:47 +01:00
jpmschweitzerandClaude Opus 4.6 51bad2e606 fix(client): bump protocol version to 15
Server bumped to v15 in Sprint 19 (save_result field) but client
constant was never updated. Fixes cross-language fixture test failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:08:45 +01:00
jpmschweitzer cf4919913e Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-25 13:07:00 +01:00
jpmschweitzer e51650ec74 Merge origin/main into client (resolve CHANGELOG conflict) 2026-02-25 13:04:01 +01:00
jpmschweitzerandClaude Opus 4.6 fcd2fd3cf7 fix(skills): pr-review must run from main worktree, no workarounds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:02:02 +01:00
jpmschweitzerandClaude Opus 4.6 b6c4ecb302 feat(ci): protocol version handshake client + IPC benchmark (#556, #342)
#556: HANDSHAKING state in sim_bridge.gd — decodes first framed message
as HandshakeMessage, validates vs Protocol.PROTOCOL_VERSION, 5s timeout,
handshake_complete/handshake_failed signals.

#342: IPC benchmark now reads and validates HandshakeMessage before
starting the timing loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:59 +01:00
jpmschweitzerandClaude Opus 4.6 e7f1e80307 fix(client): POI category key mismatch — use poi_category from wire format
protocol.gd decoded server's poi_category as "category", minimap.gd
read "category" — both now use "poi_category" matching the wire format.
Protocol falls back to "category" for older server snapshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:29 +01:00
jpmschweitzerandClaude Opus 4.6 16e1cc3a09 chore(skills): tighten sprint agent spawn rules
Add 5 new rules to the agent spawn prompt: read-before-write,
verify-after-write, no-partial-work, message-when-blocked, and
backward-compatibility. Adds verification checklist before marking
tasks done. Addresses recurring issues with agents skipping call
site updates, leaving partial implementations, and not escalating
blockers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:56:47 +01:00
jpmschweitzerandClaude Opus 4.6 ab654c8865 test(client): add sprint 18 test suites for examine display and minimap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:52:04 +01:00
jpmschweitzer 1bd528e521 Merge remote-tracking branch 'origin/main' into ci
# Conflicts:
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/perception/observer/mod.rs
2026-02-25 12:51:41 +01:00
jpmschweitzerandClaude Opus 4.6 83a244fc2c fix(client): address PR #67 round 2 — broken tests, BBCode escape, expiry loop
- Add missing _test_input_queue proxy to SimBridge (26 call sites across
  6 test files broken by TestHarness extraction)
- Parent quit dialog to SceneTree root instead of caller node to prevent
  orphaned reference if caller freed before user responds
- Remove deprecated rng.randomize() call (Godot 4 auto-seeds)
- Clear debug overlay state (_npc_paths, tick timing) on session change
  via new GameState.game_id_changed signal to prevent entity ID collisions
- Update settings_dialog quit_to_menu() call site (no-arg signature)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:47 +01:00
jpmschweitzerandClaude Opus 4.6 01b354569b chore(config): allowlist cargo and test runner commands in settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:32 +01:00
jpmschweitzerandClaude Opus 4.6 81ec337e4d feat(ci): IPC round-trip timing benchmark scaffold (#342)
Benchmark test in ipc_bench.rs: warmup + 100 rounds, p50/p95/p99 latency
reporting, 5ms threshold. Handshake step stubbed pending #555/#556.
Invoked via tests/run-ipc-benchmark.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:25 +01:00
jpmschweitzerandClaude Opus 4.6 732b5f3adc fix(simulation): resolve duplicate examine_result field and regenerate fixtures
Removed duplicate examine_result from ObserverSnapshot (old ExamineResultEvent
superseded by ExamineResultWire). Fixed serde_default test version assertion.
Regenerated golden file and all fixture .msgpack files. 1070 Rust tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:17 +01:00
jpmschweitzerandClaude Opus 4.6 7d5fdc82b6 feat(ci): IPC serialization fixture files and cross-language tests (#271)
Five new .msgpack fixtures: snapshot_minimal, snapshot_full,
player_input_move, player_input_interact, malformed. Rust generator in
gen_fixtures.rs, 7 Rust validation tests in serialization.rs, GDScript
cross-language test in test_ipc_fixtures.gd (22 assertions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:07 +01:00
jpmschweitzerandClaude Opus 4.6 4f21465dbf feat(ci): test runner scripts and Makefile integration (#270)
Six test runner scripts at tests/: run-rust, run-godot, run-ipc-fixtures,
run-ipc-protocol, run-ipc-integration, run-all. Plus run-ipc-benchmark
for Layer 3 timing. All produce structured JSON stdout, support --filter,
and exit 0/non-zero. Makefile targets updated to delegate to scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:47:58 +01:00
jpmschweitzerandClaude Opus 4.6 2493e2b0b5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:47:43 +01:00
jpmschweitzerandClaude Opus 4.6 fdad0958b3 docs(workshops): add outcomes files for 5 completed workshops
Closes content-gap-analysis, KG-information-boundaries,
v01-content-scoping, v01-gap-analysis, and wiki-review workshops
with formal outcomes recording decisions produced, tickets created,
and open questions carried forward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:45:59 +01:00
jpmschweitzer e9ca3cadf0 Merge remote-tracking branch 'origin/server' 2026-02-25 12:45:24 +01:00
jpmschweitzerandClaude Opus 4.6 23fbfdbfc5 fix(simulation): PR #68 review — version bump, tracing warns, test coverage
- Bump PROTOCOL_VERSION 14 → 15 for save_result field addition
- Add tracing::warn on SaveLoadPending command overwrite (double-tap F5)
- Add tracing::warn on KnowledgeGraph::new() fallback during save
- Fix misleading WouldBlock comment in tcp.rs
- Document SimSpacePressure.active_count pre-eviction timing
- Document entity-based eviction tie-breaking non-determinism
- Add ScopePinned eviction survival regression test
- Regenerate msgpack fixtures for protocol v15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:32:35 +01:00
jpmschweitzerandClaude Opus 4.6 c4f210a2f6 refactor(client): extract test simulation from SimBridge to TestHarness
Moves ~300 lines of test simulation logic (Bresenham LOS, collision,
procedural room generation, movement physics, dialogue triggers) from
the production sim_bridge.gd autoload into a dedicated TestHarness
class at scripts/protocol/test_harness.gd. Enforces D-020 information
boundary — no game logic in the production client.

SimBridge retains thin proxy properties and methods for backward
compatibility with 13+ test files (zero test changes needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:23:36 +01:00
jpmschweitzerandClaude Opus 4.6 f874979ff9 docs(decisions): file 6 D-records and 10 Q-records from workshop backlog
D-087 through D-092: v0.1 triangle config, 3-state pause, self-contained
forks, PC voice registers, complicity as theme, anchor line requirement.
Q-030 through Q-039: long-term strategy questions from wiki-review R4
(seed schema, generator pipeline, Sacred/Profane, 300-world estimates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:18:53 +01:00
jpmschweitzer 17a9197b65 Merge remote-tracking branch 'origin/main' into server
# Conflicts:
#	CHANGELOG.md
2026-02-25 12:14:02 +01:00
jpmschweitzerandClaude Opus 4.6 740e97b75d chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:13:23 +01:00
jpmschweitzerandClaude Opus 4.6 6ed8d11502 feat(simulation): Sprint 19 — 7 server systems
Protocol handshake (#555): HandshakeMessage as first IPC frame,
HandshakeState resource, forward-compatible input handling.

State serialization (#96): serialize_npc_to_frozen/deserialize with
full D-024 axis coverage (10 new optional fields on NpcSaveState).

Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic
assignment from KnowledgeGraph and RelationshipGraph.

Timestamp eviction (#97): LastInteractionTick, SimSpacePressure,
BinaryHeap LRU eviction respecting ScopePinned entities.

Save/load (#553): save_to_file/load_from_file via MessagePack,
SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot.

Test infrastructure (#200): Layer 3 integration test entry point,
three-layer architecture documented per D-030.

Information boundary tests (#272): 4 negative tests proving no
passive KG leakage, LOS fog holds, tier boundary holds, save
isolation per NPC.

1063 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:13:03 +01:00
jpmschweitzerandClaude Opus 4.6 5d1d0d000c fix(client): address PR #67 review — error propagation, test cleanup
SessionManager.new_game() now returns "" on dir creation failure
instead of proceeding with a broken game-id. Main menu guards
against empty return. Test suite tracks and cleans up created
save directories in after_test().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:08:48 +01:00
jpmschweitzerandClaude Opus 4.6 1d996d7ed3 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:55:43 +01:00
jpmschweitzerandClaude Opus 4.6 d5ecd9cba8 docs(workshops): add character creation & game setup workshop brief
Resolves Q-011 (character selection). Covers: creation model (fixed/
generated/custom), seed boundary table, gate activation, quest seeding,
game condition toggles. Reference games: DF, RimWorld, Sims, Disco
Elysium, Obra Dinn, Pentiment. 5 rounds, 6 participants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:55:21 +01:00
jpmschweitzerandClaude Opus 4.6 cafa892b1e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:46:07 +01:00
jpmschweitzerandClaude Opus 4.6 1d2a1fb77d feat(ui): debug visualization overlay (#348)
F3-toggled dev overlay: LOS rays, vision cone arcs, NPC path trails,
knowledge confidence tags, tick timing sparkline. Guarded by
OS.is_debug_build() for export builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:51 +01:00
jpmschweitzerandClaude Opus 4.6 ef135830f2 feat(client): game session management (#258, D-085)
Per-game save directories under user://saves/<timestamp>-<seed>/.
SessionManager autoload handles new_game(), resume_game(), quit flow.
Main menu scene with New Game / Continue / Quit buttons. Game-id
passed to server subprocess via --game-id flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:46 +01:00
jpmschweitzerandClaude Opus 4.6 a7541aa27b test(client): scene testing utilities and GameState tests (#206)
SceneHelper class for gdUnit4: load scenes into test tree with
assert_node_exists, assert_signal_emitted, get_node_at helpers.
14 tests for GameState.apply_snapshot() covering v2+ fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:39 +01:00
jpmschweitzerandClaude Opus 4.6 372949508e feat(client): gdUnit4 CI runner script (#205)
Headless test runner that delegates to GdUnitTestCIRunner for CI
integration. Exit code 0 = all pass, non-zero = failures per D-030.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:34 +01:00
jpmschweitzerandClaude Opus 4.6 0dd33690f7 docs(sprints): add Sprint 19: Persist briefings
Server (7), client (5), CI (4) briefings plus joint integration plan.
Save/load with D-085 per-game dirs, tier eviction/scope, test infra.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:29:41 +01:00
jpmschweitzerandClaude Opus 4.6 75e821ccd7 chore(config): gitignore agent-memory scratch files
Session-specific working notes that are context-brittle and would
create noise across branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:15:42 +01:00
jpmschweitzerandClaude Opus 4.6 511fdfef46 docs(wireframes): update save/load wireframe for D-085 per-game grouping
LOAD tab now shows games grouped by directory with expand/collapse.
QUICKSAVE slot distinct from AUTOSAVE. F5/F6 keybind hints in footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:10:01 +01:00
jpmschweitzerandClaude Opus 4.6 0cf4dcc3f9 feat(db): decision ID claim system — prevent cross-worktree collisions
- `db/connectors/decision next [D|Q|R]` — show next available ID
- `db/connectors/decision claim <prefix> <domain> [title]` — reserve ID in DB
- `db/connectors/decision check-dupes` — detect duplicate IDs in markdown
- `tooling/check-decision-ids` — pre-commit hook for dupe detection
- D-035 added as known exception (139 files, too embedded to renumber)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:51:46 +01:00
jpmschweitzerandClaude Opus 4.6 7ca1b78153 docs(decisions): renumber insert icon system D-084 → D-086
Resolves ID collision: D-084 was used independently by both the copy
branch (line ID scheme, content.md) and the visual branch (insert icon
system, perception.md). Content's D-084 keeps the number; visual's is
renumbered to D-086.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:45:13 +01:00
jpmschweitzerandClaude Opus 4.6 ff7f99da7a chore(db): backup database after Sprint 18 close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:44:23 +01:00
jpmschweitzer 079f98dc2e Merge remote-tracking branch 'origin/visual'
# Conflicts:
#	decisions/README.md
2026-02-25 10:44:09 +01:00
jpmschweitzerandClaude Opus 4.6 262cac5849 docs(decisions): add D-085 per-game save dirs, Q-029 save file format
D-085: per-game save directory structure with F5/F6 quicksave/quickload.
Q-029: open question tracking long-term save file format design.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:43:21 +01:00
jpmschweitzer f5c5ac635c Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/bridge/types.rs
2026-02-25 10:09:27 +01:00
jpmschweitzerandClaude Opus 4.6 768a431c38 fix(simulation): PR #66 review — dedup vision, spawn components, doc fixes
- vision.rs: deduplicate own-tile entity iteration, add same-tile test
- spawn.rs: add NpcVisionState, NpcMemory, PlayerAwareness to content-
  spawned NPCs (matching generate_npc)
- pressure.rs: update who_knows_full_scan doc to reflect actual call
  frequency, document O(N) acceptability at call site
- types.rs: fix stale protocol version comment (13→14)
- generate.rs: format!() → .to_string() (clippy)
- vision.rs: hardcoded 10 → TICKS_PER_GAME_MINUTE
- relationships.rs: fix comment "15%" → "20%" to match code
- pressure.rs: document total() floor-truncation
- save_state.rs: document NpcMemory exclusion as intentional

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:04:18 +01:00
jpmschweitzer bdd24ef2d5 Merge remote-tracking branch 'origin/copy'
# Conflicts:
#	CHANGELOG.md
2026-02-25 09:59:20 +01:00
jpmschweitzerandClaude Opus 4.6 abd1657d94 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:51:09 +01:00
jpmschweitzerandClaude Opus 4.6 31ae462cb2 test(simulation): update protocol fixtures and golden for Sprint 18
Regenerated MsgPack fixtures for protocol v14 (examine_result and
character_pressure fields). Updated golden test, serialization
assertions, and cross-language fixture generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:50:48 +01:00
jpmschweitzerandClaude Opus 4.6 276538cde1 feat(simulation): Sprint 18 — 9 server systems
Background tier state machines (#95), NPC vision (#115), player-
awareness behavior (#244), skill system & combat flag (#91), social
propagation (#249), examine mechanic (#242), character pressure
framework (#248), save state data model (#256), tell state wiring
(#337). Protocol version bumped 13→14 for examine_result and
character_pressure snapshot fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:50:37 +01:00
jpmschweitzerandClaude Opus 4.6 cc898d60c7 fix(content): PR #64 round 2 — NPC ID, outcome gating, fact stubs
Fix npc:nils → npc:nils-davan canonical ID mismatch. Replace
auto-flag gates on ring_completes/ring_splinters with player-
action-gated fact conditions (unreachable outcomes). Add
ring_stalemate terminal exit for non-terminal ring_splinters.
Create ring.yaml with 10 fact stubs for content/global/knowledge/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:49:09 +01:00
jpmschweitzerandClaude Opus 4.6 776ba2e55f fix(client): address PR #65 round 2 — broken tests, BBCode escape, expiry loop
- test_journal_sprint18.gd: replace references to removed
  CONFIDENCE_LABELS/SOURCE_LABELS with UIStrings key tests and
  regression guards
- dialogue_box.gd: escape ] as [rb] in _escape_bbcode for complete
  BBCode injection protection
- dialogue_box.gd: fix _expire_entries to skip pinned entries with
  continue instead of break, cleaning expired entries behind pins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:44:42 +01:00
jpmschweitzerandClaude Opus 4.6 c55474f46d fix(content): PR #64 review — schema compliance and orphaned flags
Fix events_fired referencing flag names instead of event IDs (two
outcomes unreachable). Wire 4 orphaned flags into narratively
coupled outcome conditions. Fix typo and stale question count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:42:43 +01:00
jpmschweitzerandClaude Opus 4.6 a82eb0748a fix(client): address PR #65 review — POI key, ToldBy parsing, KG dirty flag
- minimap.gd: fix "poi_category" → "category" key mismatch so POI
  colors and shapes render correctly
- journal_panel.gd: parse ToldBy(N) source format, resolve entity
  names from player_knowledge; move confidence/source labels to
  UIStrings per D-042
- observer/mod.rs: add Changed<KnowledgeGraph> dirty flag to skip
  per-tick KG serialization when unchanged
- types.rs: fix stale version doc comment (13 → 14)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:41:31 +01:00
jpmschweitzerandClaude Opus 4.6 a52cf4f494 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:30:28 +01:00
jpmschweitzerandClaude Opus 4.6 dae16326fe test(client): sprint 18 test suite — dialogue, journal, minimap (#151, #174, #264)
50 tests across two files: 30 dialogue tests (D-062 compliance,
D-063 confrontation beat, D-064 walk-away, BBCode guard, size
constraints) and 20 journal tests (KG parsing, confidence/source/
state enums, scene structure, UIStrings keys, POI list). Test plan
document with manual procedures and sprint completion checklist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:30:13 +01:00
jpmschweitzerandClaude Opus 4.6 86ff83d927 feat(ui): knowledge/journal display panel (#264)
Right-side insert panel toggled with J key. Facts grouped by entity
with confidence, source, and state metadata. Contradicted entries
in amber with strikethrough (THE FRIEND arc surface). Stale entries
dimmed. Mutual exclusion with dialogue box. All labels via UIStrings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:30:04 +01:00
jpmschweitzerandClaude Opus 4.6 9b80ff69c3 feat(ui): dialogue UI hardening and examine result overlay (#174)
Confrontation options use RichTextLabel with italic tags for
first-person voice (D-063). Examine result overlay auto-dismisses
after 5s with confidence-based color tinting. Dismisses when
dialogue opens. D-062 invisible locked options confirmed correct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:29:55 +01:00
jpmschweitzerandClaude Opus 4.6 eeb82535f6 feat(ui): minimap rendering with POI dots and border arrows (#151)
Circular 160px diegetic insert overlay on CanvasLayer 10. Nearby
POIs as colored dots (diamond=danger, square=evidence, circle=default),
distant POIs as directional border arrows. Player centered, fixed-north.
Moved from UILayer to InsertOverlay per D-013/D-049.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:29:46 +01:00
jpmschweitzerandClaude Opus 4.6 80064196f7 feat(client): protocol v14 decoders and game state fields
Decode poi_list, examine_result, and player_knowledge from
ObserverSnapshot. Add GameState.discovered_pois,
current_examine_result, and player_knowledge fields populated
from snapshot apply handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:29:37 +01:00
jpmschweitzerandClaude Opus 4.6 dd3ead7a1b feat(simulation): protocol v14 — POI list, examine result, player knowledge wire types
Add three new ObserverSnapshot fields for client Sprint 18 tickets:
poi_list (Vec<PoiWire>) for minimap #151, examine_result
(Option<ExamineResultWire>) for #174, player_knowledge
(Option<PlayerKnowledgeWire>) for journal #264. POI list populated
live from KG-discovered PointOfInterest components. KG dump
serializes entity/fact knowledge with confidence, source, and state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:29:29 +01:00
jpmschweitzerandClaude Opus 4.6 2493dcb5f0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:57 +01:00
jpmschweitzerandClaude Opus 4.6 16a79c928c feat(content): Tier 1 drama module schema and smuggling ring stub (#158)
JSON Schema for drama modules covering entry conditions, NPC
requirements, event sequences, and outcomes. Includes v0.1
vertical slice stub module and authoring guide with review
feedback from Mellanie (pattern/motivation reference, terminal
outcome semantics, fact ID conventions, trigger type bridge).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:39 +01:00
jpmschweitzerandClaude Opus 4.6 9cec3df68c feat(content): D-084 dual-namespace line ID scheme — resolve Q-028 (#544)
Role namespace (shared pool) stays unchanged; instance namespace
(role-slug + seeded counter) added as opt-in for per-instance
authored content on auto-generated NPCs. No schema migration needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:33 +01:00
jpmschweitzer 4c812058f2 chore(meta): resolve CHANGELOG conflict — merge main into visual 2026-02-25 02:04:45 +01:00
jpmschweitzerandClaude Opus 4.6 2eca8e961b chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:43:05 +01:00
jpmschweitzerandClaude Opus 4.6 86aab2d14e docs(sprints): add Sprint 18: Touch briefings
14 tickets across server (9), client (3), copy (2). Examine mechanic,
NPC awareness, social propagation, minimap, dialogue UI, save state
data model, and blocker-clearing work for Sprint 19.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:42:52 +01:00
jpmschweitzer d1ca99b0c0 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
2026-02-24 13:00:39 +01:00
jpmschweitzerandClaude Opus 4.6 132676c25a chore(simulation): address review suggestions — comments and minor fixes
- monologue.rs: tracing::warn on unrecognized trigger in fallback arm
- disclosure.rs: borrow-sequencing comment, first() simplification note
- trait_modifiers.rs: delegate parse_confidence to KnowledgeConfidence::try_from
- npc_knowledge_transfer.rs: fix misleading "draw randomly" comment,
  document one-directional transfer behavior
- graph.rs: document intentional no-retrigger after contradiction
- dialogue.rs: explain entity grant guardrail asymmetry (D-079)
- knowledge/mod.rs: document one-tick monologue lag from system ordering
- poi_discovery.rs: document D-079 carve-out for direct KG write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:45:47 +01:00
jpmschweitzerandClaude Opus 4.6 219af065d8 chore(skills): remove temp file pattern from pr-push skill
Pass PR description inline instead of writing to /tmp/pr-body.md
and cat-ing it back — matches the tea-comment wrapper approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:44:20 +01:00
jpmschweitzerandClaude Opus 4.6 c655878dee fix(simulation): review fixes — walk-away source and Loyal trait filter
record_incomplete_interaction now uses Heard/Close source instead of
DirectObservation, preventing false inoculation against contradiction
detection. Wired exclude_high_trust_entities (Loyal trait) filter into
derive_disclosure_candidates — facts from Friendly-relationship source
entities are excluded from disclosure pool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:42:35 +01:00
jpmschweitzerandClaude Opus 4.6 0beeb32ff7 chore(db): backup database after maintenance merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:41:54 +01:00
jpmschweitzer c2eb8fdf8f Merge remote-tracking branch 'origin/maintenance'
# Conflicts:
#	CLAUDE.md
2026-02-24 12:41:43 +01:00
jpmschweitzerandClaude Opus 4.6 ccbfa6bd72 chore(skills): add branch guard to pr-review skill
Reviews must run from main. Added step 0 hard stop if current branch
is not main, preventing repeated misuse from team branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:24:06 +01:00
jpmschweitzer 7ec0eab541 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-24 12:19:35 +01:00
jpmschweitzerandClaude Opus 4.6 2a4f096b4a Merge remote-tracking branch 'origin/copy'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:17:17 +01:00
jpmschweitzerandClaude Opus 4.6 6134c26afc Merge remote-tracking branch 'origin/visual'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:16:52 +01:00
jpmschweitzerandClaude Opus 4.6 86ffa2fa4f chore(meta): merge main into server — resolve CHANGELOG conflict
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:16:41 +01:00
jpmschweitzerandClaude Opus 4.6 5e3c2a9799 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:56 +01:00
jpmschweitzerandClaude Opus 4.6 615f508634 chore(meta): add team monitoring rules for stuck agent detection
Heartbeat rule for agent spawn prompts (15-min progress check) and
team lead proactive checks (bottleneck detection pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:37 +01:00
jpmschweitzerandClaude Opus 4.6 22724ee51c test(simulation): protocol versioning tests (#232)
Version field round-trip, mismatch detection, serde_default migration
pattern, all TellCategory and VerbKind variant coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:29 +01:00
jpmschweitzerandClaude Opus 4.6 d5cf97a793 feat(simulation): unprompted disclosure with trait-based filtering (#551, #173)
DisclosureCandidates component with 7 trigger gates, three-layer rate
limiting, and two-stage trait filter (what + how). Cautious/Gossipy/Loyal/
Talkative predicates via TraitModifierConfig. POI component and discovery
system. Implements D-081, D-082 step 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:21 +01:00
jpmschweitzerandClaude Opus 4.6 b86e052511 feat(simulation): NPC information boundaries and contradiction monologue (#549, #550)
tell_state reads KG for other-entity relationship state (MVP boundary
per D-082). Contradiction monologue fires with pre-resolved entity names,
shifts ToldBy source to PersonOfInterest. Full THE FRIEND arc event chain.
Implements D-082 step 1, D-083 event chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:11 +01:00
jpmschweitzerandClaude Opus 4.6 7487eb9dbb feat(simulation): NPC-to-NPC knowledge transfer and POI systems (#548, #148, #149)
transfer_npc_knowledge system runs after conversations with trust-gated
fact exchange, confidence capping at KnowsOf, ToldBy source construction.
POI data model with PointOfInterest component and proximity-based discovery
via KnowledgeGranted events. Implements D-080. Closes Q-024.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:15:02 +01:00
jpmschweitzerandClaude Opus 4.6 3d636fd4bb feat(simulation): knowledge grant schema, events, and contradiction detection (#545, #546, #547)
KnowledgeGrant untagged enum (Fact + Entity variants), ContentEntityRegistry
resource, KnowledgeGranted event processing, ContradictionClaim struct with
600-tick window detection in observe_entity. Wires knowledge_grant field in
dialogue line selection. Implements D-079, D-083. Closes Q-026.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:14:53 +01:00
jpmschweitzerandClaude Opus 4.6 f5a866fc4d fix(content): PR #61 round 2 — schema fields, cooldown, CHANGELOG sections
Contradiction files:
- Add role/access/trust back to all lines (schema-required)
- prerequisite: → prerequisites: (plural, matching schema)
- Remove cooldown entirely — fire-once enforced by trigger semantics
  (contradiction_detected fires once per KG state change), not magic numbers

Tutorial files:
- Remove cooldown: -1 — same reasoning, first_* triggers are one-time

Server:
- Document cooldown: Option<i32> on MonologueLine — None means no
  per-line cooldown, fire-once relies on trigger design

CHANGELOG:
- Move displaced entries from ### Fixed back to ### Changed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:05:16 +01:00
jpmschweitzerandClaude Opus 4.6 92f262bebf chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:38:14 +01:00
jpmschweitzerandClaude Opus 4.6 2ce71dedf6 docs(decisions): register D-084 insert icon system — custom SVG, no icon font
Insert overlay uses custom SVG icons instead of an icon font. No
available font matches the neural lattice visual language. Custom
SVGs respond to lattice_profile weight parameter and maintain visual
coherence with existing geometric primitives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:37:58 +01:00
jpmschweitzerandClaude Opus 4.6 b544303f29 docs(meta): allow removing own worktree index.lock in CLAUDE.md
Adds explicit exception to worktree boundary rules: agents may
remove their own worktree's stale index.lock file from the shared
.git directory, but never another worktree's lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:37:37 +01:00
jpmschweitzerandClaude Opus 4.6 766a8b9c6d fix(content): PR #61 review — schema compliance for contradiction + tutorial
Contradiction files (both characters):
- location: contradiction-detected → contradiction (valid content ID)
- situation: removed invalid values (contradiction, reflection)
- prerequisites: (plural) → prerequisite: (singular) with canonical entities map
- Removed source_name from prerequisites (event payload, not KG gate)
- Removed phase: field (moved to tags as phase-2/phase-3)
- trigger: contradiction_processing → contradiction_detected with priority sequencing
- Removed dialogue-only fields (role, access, trust) from monologue lines
- Phase 3 detective: prerequisite uses torek-lintar PersonOfInterest
- Phase 3 smuggler: prerequisite uses tells_observed on entity

Tutorial files (both characters):
- priority: "tutorial" (string) → 8/6 (integer)
- situation: removed invalid values (movement, exploration)

CHANGELOG: merged duplicate ### Changed sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:32:43 +01:00
jpmschweitzerandClaude Opus 4.6 821d716509 fix(client): address PR #62 review suggestions — polish pass
- interaction_list.gd: skip queue_redraw() when position unchanged (Tyre #4)
- time_display.tscn: widen bounding rect 154x44→184x54, add clip note
  for #314 wireframe (Tyre #3)
- test_time_display_sprint17.gd: document private state access pattern
  in header (Tyre #5), add upper bound assertion for D-031 1439 max (Hoshe #6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:30:24 +01:00
jpmschweitzerandClaude Opus 4.6 16c00e137e fix(client): address PR #62 review — input guard, public API, geometry cache
- constants.gd: clamp format_game_time input to 0..1439 (Hoshe #2)
- interaction_list.gd: add public hide_list() wrapper (Hoshe #3, Tyre #1)
- main.gd: call hide_list() instead of private _hide()
- time_display.gd: cache font geometry in update_from_state(), use boolean
  _has_data flag instead of string guard (Tyre #2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:28:43 +01:00
jpmschweitzerandClaude Opus 4.6 d8eb256dad test(client): align sprint 17 tests with implementation (#263, #537)
Fix test API mismatches: time display tests target InsertOverlay/
TimeDisplay and time_display.gd; E-Talk tests rewritten to target
interaction_list.gd _cache_entity_relationship() and _relationship_color
state. Phase 2 tests (name, tier hint) marked as skip placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:22:39 +01:00
jpmschweitzerandClaude Opus 4.6 2c15ad65e1 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:46 +01:00
jpmschweitzerandClaude Opus 4.6 6c3a30a4bc feat(ui): add relationship color accent to E-Talk overlay (#537)
Phase 1: interaction_list.gd shows a 3px left-edge accent bar in
D-033 relationship color (teal/green/amber/red) at 85% alpha.
Cross-references entity_id against visible_entities via
_cache_entity_relationship(). NPC name and tier hint deferred to
Phase 2 (requires server protocol extension).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:33 +01:00
jpmschweitzerandClaude Opus 4.6 66a435e201 feat(ui): add diegetic time display on insert HUD (#263)
Time display on InsertOverlay (CanvasLayer 10) shows station local
time (HH:MM), day phase with cycle-tinted color, and day number.
Reads SimulationTime from GameState.game_time via update_from_state().
Adds Constants.format_game_time() helper for testability.
Placeholder layout — position refines when #314 wireframe lands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:23 +01:00
jpmschweitzerandClaude Opus 4.6 badc0a6f20 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:13 +01:00
jpmschweitzerandClaude Opus 4.6 da34b92421 feat(content): author diegetic tutorial monologue lines — per character (#330)
10 lines per character teaching 5 core mechanics diegetically:
movement/exploration, fog of perception, sound model, NPC interaction,
insert/HUD. Detective lines in analytical register, smuggler lines in
street-smart fragments. Fires on first-time events (first_move,
first_fog_encounter, first_sound_heard, first_npc_proximity,
first_insert_open, etc.). _tut_ ID discriminator, cooldown -1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:01 +01:00
jpmschweitzerandClaude Opus 4.6 fa4d30a951 feat(content): author contradiction monologue lines — Sera/Kael FRIEND arc (#552)
Source-named contradiction monologue for both detective and smuggler.
Detective: Sera/Kael location contradiction (Phase 2 blindsiding +
Phase 3 pattern recognition with Torek avoidance). Smuggler: Kael in
B-7 with unrecognized contact (Phase 2 gut reaction + Phase 3
behavioral pattern accumulation). Cognitive-dissonance-not-accusation
tone per D-083. 16 hand-authored lines total (8 per character).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:13:54 +01:00
jpmschweitzerandClaude Opus 4.6 5f0e19547f chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:06:34 +01:00
jpmschweitzerandClaude Opus 4.6 9bda80174c docs(ui): add insert/HUD wireframe spec — dual character variants
Pixel-precise wireframe and visual specification for the neural lattice
overlay HUD (#314). Covers smuggler (social network view) and detective
(investigation overlay) variants via lattice_profile parameter.

Specifies: time display, entity markers, location labels, border arrows,
commission grid, inventory icons, stance indicator, and all interaction
states. Includes implementation notes for Stig (#263 time display).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:06:25 +01:00
164 changed files with 25217 additions and 607 deletions
+14
View File
@@ -17,3 +17,17 @@ When producing many files (wiki pages, content batches, bulk docs):
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style 3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume. Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
## Team monitoring (stuck agent detection)
When leading a team (sprint, workshop, or any multi-agent session):
**Agent heartbeat rule** — include in every agent spawn prompt:
> If you have been working on a single task for more than 15 minutes
> without making progress, message the team lead with what is blocking
> you. Do not keep retrying the same approach silently.
**Team lead proactive checks:**
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
+8
View File
@@ -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 *)",
+1 -5
View File
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description. Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
```bash ```bash
cat > /tmp/pr-body.md << 'EOF'
## Summary
...
EOF
tea pr create \ tea pr create \
--repo jpmschweitzer/settled-reach \ --repo jpmschweitzer/settled-reach \
--login schweitz \ --login schweitz \
--title "<title>" \ --title "<title>" \
--description "$(cat /tmp/pr-body.md)" \ --description "## Summary ..." \
--base main \ --base main \
--head <branch> --head <branch>
``` ```
+15 -4
View File
@@ -16,11 +16,22 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow ## Workflow
### 1. Determine the branch ### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
If the user provided a branch name as argument, use it. Otherwise use the ```bash
current branch (`git branch --show-current`). If on `main`, ask the user git branch --show-current
which branch to review. ```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run by a Claude instance in the `main` worktree."
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
If the user provided a branch name as argument, use it. Otherwise list open
PRs and ask the user which branch to review.
To list open PRs on Gitea: To list open PRs on Gitea:
```bash ```bash
+51 -12
View File
@@ -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}",
+1
View File
@@ -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 ""
+1
View File
@@ -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/
+84 -8
View File
@@ -6,9 +6,82 @@ 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)
- 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)
- 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
- 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
- `.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)
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies - `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
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team - `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection) - `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline - Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
@@ -17,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
@@ -35,6 +113,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338) - Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
- DialogueResponse cross-language fixture for GDScript testing - DialogueResponse cross-language fixture for GDScript testing
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown - Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
- pr-push and pr-review skills updated with team lifecycle awareness
### Fixed ### Fixed
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values - PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
@@ -49,14 +133,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- assert!(false) → panic!() in serialization tests (clippy) - assert!(false) → panic!() in serialization tests (clippy)
- SetFacing and TeleportToHub added to roundtrip test coverage - SetFacing and TeleportToHub added to roundtrip test coverage
### Changed
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
- pr-push and pr-review skills updated with team lifecycle awareness
## [v0.1.15] — 2026-02-23 ## [v0.1.15] — 2026-02-23
### Added ### Added
+3
View File
@@ -34,6 +34,7 @@ This project uses **git worktrees** in a shared parent directory (`settled-reach
- **All work must remain within the git root** unless explicitly instructed otherwise. - **All work must remain within the git root** unless explicitly instructed otherwise.
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`). - All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed. - Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
### Database ### Database
@@ -55,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
+25 -9
View File
@@ -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 ---
+16
View File
@@ -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."
@@ -125,6 +128,17 @@ knowledge_panel:
confidence_medium: "Likely" confidence_medium: "Likely"
confidence_low: "Unconfirmed" confidence_low: "Unconfirmed"
confidence_rumor: "Hearsay" confidence_rumor: "Hearsay"
# D-041 KnowledgeConfidence levels — displayed in journal panel
confidence_direct: "Confirmed"
confidence_knowsdetails: "Detailed"
confidence_knowsof: "Known"
confidence_suspects: "Unconfirmed"
# D-041 KnowledgeSource labels — displayed in journal panel
source_directobservation: "Observed"
source_toldby: "Told"
source_heard: "Overheard"
source_inferred: "Inferred"
source_background: "Prior"
# ============================================================ # ============================================================
# TUTORIAL TEXT (DIEGETIC) # TUTORIAL TEXT (DIEGETIC)
@@ -159,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"
+17 -1
View File
@@ -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]
@@ -125,11 +126,26 @@ debug_overlay={
"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":4194334,"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
open_journal={
"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":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
]
}
teleport_hub={ teleport_hub={
"deadzone": 0.5, "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":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]
+16 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"] [gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -22,6 +22,9 @@
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"] [ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
[node name="Game" type="Node2D"] [node name="Game" type="Node2D"]
script = ExtResource("1_main") script = ExtResource("1_main")
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
[node name="InsertOverlay" type="CanvasLayer" parent="."] [node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10 layer = 10
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display ; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")] [node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
@@ -130,6 +136,15 @@ layer = 10
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI ; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")] [node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
; --- UI layer (CanvasLayer 20) --- ; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera. ; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."] [node name="UILayer" type="CanvasLayer" parent="."]
@@ -137,8 +152,6 @@ layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")] [node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")] [node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
; D-053: Stance indicator — top-right, color-coded ; D-053: Stance indicator — top-right, color-coded
+66
View File
@@ -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)
+55
View File
@@ -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}]
@@ -70,6 +85,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}] var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
var conversation_ended: Array = [] # [{speaker_id, target_id}] var conversation_ended: Array = [] # [{speaker_id, target_id}]
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
# Populated from snapshot "poi_list" field — only updated when field present.
var discovered_pois: Array = []
# v14 fields (#174, #242): Character-filtered examine result.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var current_examine_result: Variant = null
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
# #126, D-018: Medium-range sound events for fog-edge directional indicators. # #126, D-018: Medium-range sound events for fog-edge directional indicators.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick. # Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = [] var medium_sound_events: Array = []
@@ -240,6 +270,31 @@ func apply_snapshot(snapshot: Dictionary) -> void:
medium_sound_events = [] medium_sound_events = []
close_sound_events = [] close_sound_events = []
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
# Only update if the field is present — absence means "no change since last tick".
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
discovered_pois = snapshot.discovered_pois
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
discovered_pois = snapshot.poi_list
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
current_examine_result = snapshot.examine_result
else:
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.
# 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:
player_knowledge = snapshot.player_knowledge
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles # D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key). # (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered. # Must use the member var, not snapshot.visible_tiles, so test mode is covered.
+19 -2
View File
@@ -19,8 +19,11 @@ enum Action {
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
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] = []
@@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"): elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT action = Action.BUG_REPORT
elif event.is_action_pressed("open_journal"):
action = Action.OPEN_JOURNAL
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()
+130
View File
@@ -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
+139 -342
View File
@@ -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"
+5
View File
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# two columns of text comfortably, leaves world game visible alongside. # two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200 const DIALOGUE_MAX_WIDTH: int = 1200
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var clamped: int = clampi(time_of_day, 0, 1439)
return "%02d:%02d" % [clamped / 60, clamped % 60]
# Default camera zoom — used as fallback when get_camera_2d() returns null # Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
+76 -1
View File
@@ -14,6 +14,10 @@ extends Node2D
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@@ -98,13 +102,15 @@ func _process(delta: float) -> void:
interaction_list.set_insert_active(insert_state) interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"): if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state) interaction_prompt.set_insert_active(insert_state)
if minimap and minimap.has_method("set_insert_active"):
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state # D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise # Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"): if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active(): if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing(): if interaction_list.is_showing():
interaction_list._hide() interaction_list.hide_list()
else: else:
interaction_list.update_from_state() interaction_list.update_from_state()
@@ -131,6 +137,14 @@ func _process(delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"): if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state() checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #264: Update journal panel — auto-close on dialogue, refresh if open
if journal_panel and journal_panel.has_method("update_from_state"):
journal_panel.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool) # #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"): if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state() debug_overlay.update_from_state()
@@ -145,6 +159,9 @@ func _process(delta: float) -> void:
# Only activates when no dialogue/confrontation dip is active (D-070). # Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus() _update_listening_focus()
# #174: Show examine result if server sent one this tick (#242)
_consume_examine_result()
# Show monologue if server sent one this tick (#414) # Show monologue if server sent one this tick (#414)
_consume_monologue() _consume_monologue()
@@ -156,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.
@@ -179,6 +199,10 @@ func _process(delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active(): if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture() bug_report_dialog.start_capture()
continue continue
# #264: J — client-only, toggle knowledge journal panel
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU: if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog: if settings_dialog:
@@ -313,6 +337,9 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null GameState.current_dialogue = null
return return
_last_dialogue_tick = GameState.current_tick _last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens (cannot be open simultaneously)
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
var dlg: Dictionary = GameState.current_dialogue var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1) _last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "") _last_dialogue_npc_name = dlg.get("npc_name", "")
@@ -357,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({
@@ -451,6 +499,33 @@ func _teleport_transition() -> void:
tween.tween_callback(_flash_rect.queue_free) tween.tween_callback(_flash_rect.queue_free)
# #174: Consume examine result — show overlay when server sends character-filtered observation.
# Clears after display (single-consume). Dismiss examine when dialogue opens.
func _consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
# Dismiss existing examine result if dialogue is active (focus priority)
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
# #264: Toggle journal panel. Called from input handler when J key pressed.
func _toggle_journal() -> void:
if not journal_panel:
return
# Journal and dialogue cannot be open simultaneously (sprint briefing)
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash — fades from color to transparent over duration. # #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. # Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void: func _screen_flash(color: Color, duration: float) -> void:
+80 -1
View File
@@ -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 = 13 const PROTOCOL_VERSION: int = 15
# -- Decode: bytes from server → GDScript types -------------------------------- # -- Decode: bytes from server → GDScript types --------------------------------
@@ -206,6 +206,81 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"target_id": int(raw_end.get("target_id", 0)), "target_id": int(raw_end.get("target_id", 0)),
}) })
# v14: poi_list (#151) — discovered POIs for minimap rendering.
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
var poi_list: Array = []
var raw_pois: Variant = raw.get("poi_list")
if raw_pois is Array:
for raw_poi in raw_pois:
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
poi_list.append({
"poi_id": str(raw_poi["poi_id"]),
"name": str(raw_poi.get("name", "")),
"x": int(raw_poi["x"]),
"y": int(raw_poi["y"]),
"z": int(raw_poi.get("z", 0)),
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
})
# v14: examine_result (#174, #242) — character-filtered observation text.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var examine_result: Variant = null
var raw_examine: Variant = raw.get("examine_result")
if raw_examine is Dictionary and raw_examine.has("text"):
examine_result = {
"entity_id": int(raw_examine.get("entity_id", 0)),
"text": str(raw_examine["text"]),
"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.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
var raw_pk: Variant = raw.get("player_knowledge")
if raw_pk is Dictionary:
var kg_entities: Array = []
var raw_kg_entities: Variant = raw_pk.get("entities")
if raw_kg_entities is Array:
for raw_ke in raw_kg_entities:
if raw_ke is Dictionary and raw_ke.has("entity_id"):
kg_entities.append({
"entity_id": int(raw_ke["entity_id"]),
"name": str(raw_ke.get("name", "Unknown")),
"confidence": str(raw_ke.get("confidence", "Suspects")),
"source": str(raw_ke.get("source", "")),
"state": str(raw_ke.get("state", "Active")),
"relationship": str(raw_ke.get("relationship", "Unknown")),
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
})
var kg_facts: Array = []
var raw_kg_facts: Variant = raw_pk.get("facts")
if raw_kg_facts is Array:
for raw_kf in raw_kg_facts:
if raw_kf is Dictionary and raw_kf.has("fact_id"):
kg_facts.append({
"fact_id": str(raw_kf["fact_id"]),
"confidence": str(raw_kf.get("confidence", "Suspects")),
"source": str(raw_kf.get("source", "")),
"state": str(raw_kf.get("state", "Active")),
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
})
player_knowledge = {
"entities": kg_entities,
"facts": kg_facts,
}
return { return {
"tick": tick, "tick": tick,
"entities": entities, "entities": entities,
@@ -223,6 +298,10 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"pending_recognitions": pending_recognitions, "pending_recognitions": pending_recognitions,
"conversation_events": conversation_events, "conversation_events": conversation_events,
"conversation_ended": conversation_ended, "conversation_ended": conversation_ended,
"poi_list": poi_list,
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
} }
+334
View File
@@ -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"
+362 -17
View File
@@ -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
+1
View File
@@ -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.
+30
View File
@@ -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)
+317
View File
@@ -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()
+598
View File
@@ -0,0 +1,598 @@
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
## D-064 (walk-away), D-078 (overheard log)
##
## Test plan from joint.md:
## "Manual: examine result appears as overlay, auto-dismisses.
## Dialogue options confirmed: no locked/grayed options visible.
## Confrontation option in italic voice."
##
## Unit-testable coverage here:
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
## - D-063: confrontation beat duration, signal, dim alpha
## - D-064: walk-away fires dialogue_dismissed signal
## - GameState: current_dialogue parsing
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
## - BBCode: escape contract (Hoshe #2 regression guard)
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
class_name TestDialogueSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
var opts: Array = []
for i in range(texts.size()):
var opt: Dictionary = {
"text": texts[i],
"response_id": "r%d" % i,
"priority": i,
}
if confrontation_flags.size() > i:
opt["confrontation"] = confrontation_flags[i]
opts.append(opt)
return opts
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
func after_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
# ---------------------------------------------------------------------------
# D-062: No locked/grayed options
## Per D-062: locked options are INVISIBLE — not shown at all.
## The client renders all received options as active, clickable labels.
## Server responsibility: omit locked options from the array.
## Test verifies: no disabled/locked styling is applied to any rendered option.
# ---------------------------------------------------------------------------
func test_d062_rendered_options_have_no_disabled_state() -> void:
## D-062: All options from server render as active controls.
## No option should have mouse_filter=IGNORE (which would indicate disabled).
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
var labels := options_container.get_children()
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
for label in labels:
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
assert_int(label.mouse_filter).override_failure_message(
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
box.queue_free()
func test_d062_no_lock_icon_children_on_options() -> void:
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
## Any child node on an option label would indicate a locked-option indicator.
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
var labels := options_container.get_children()
assert_int(labels.size()).is_greater(0)
for label in labels:
assert_int(label.get_child_count()).override_failure_message(
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
).is_equal(0)
box.queue_free()
func test_d062_max_options_constant_is_three() -> void:
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
## are shown — server must omit locked ones, client only truncates to 3.
## Verify MAX_OPTIONS constant is locked at 3.
var box := _make_dialogue_box()
if box == null: return
assert_int(box.MAX_OPTIONS).override_failure_message(
"MAX_OPTIONS must be 3 per D-061 spec"
).is_equal(3)
box.queue_free()
func test_d062_server_sends_four_options_only_three_render() -> void:
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
## only top 3 by priority render. No 4th option appears.
var box := _make_dialogue_box()
if box == null: return
var opts := _make_options(["A", "B", "C", "D"])
# Assign explicit priorities so sort is deterministic
for i in range(opts.size()):
opts[i]["priority"] = i
box.show_dialogue("NPC", "Speech.", opts)
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
assert_int(options_container.get_child_count()).override_failure_message(
"Only 3 options must render even when server sends 4 (D-061 truncation)"
).is_equal(3)
box.queue_free()
# ---------------------------------------------------------------------------
# D-063: Confrontation beat
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
## Panel dims during beat. confrontation_monologue signal fires.
# ---------------------------------------------------------------------------
func test_d063_beat_duration_within_spec() -> void:
## D-063: The beat duration must be 1–2 seconds per spec.
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
"D-063: confrontation beat must be 1.0–2.0 seconds"
).is_between(1.0, 2.0)
box.queue_free()
func test_d063_dim_alpha_is_set() -> void:
## D-063: The dialogue box dims during the confrontation beat.
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
).is_less(1.0)
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
).is_greater(0.0)
box.queue_free()
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
var received_text := ""
box.confrontation_monologue.connect(func(text: String, _dur: float):
signal_fired = true
received_text = text
)
# Show dialogue with one confrontation option
var opts := _make_options(["I know what you did."], [true])
box.show_dialogue("NPC", "Everything is fine.", opts)
# Press option 1 (index 0)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
).is_true()
box.queue_free()
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
box.confrontation_monologue.connect(func(_text: String, _dur: float):
signal_fired = true
)
var opts := _make_options(["A normal response."], [false])
box.show_dialogue("NPC", "Hello.", opts)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue must NOT fire for standard options"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-064: Walk-away mechanic
## WASD during active conversation fires dialogue_dismissed signal.
# ---------------------------------------------------------------------------
func test_d064_walk_away_actions_constant_not_empty() -> void:
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
## Prevents accidental empty-array regression.
var box := _make_dialogue_box()
if box == null: return
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
).is_greater_equal(4)
box.queue_free()
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
var box := _make_dialogue_box()
if box == null: return
var actions: Array = box._WALK_AWAY_ACTIONS
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
assert_bool(required in actions).override_failure_message(
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
).is_true()
box.queue_free()
func test_d064_dialogue_dismissed_signal_connection() -> void:
## D-064: dialogue_dismissed signal must exist on DialogueBox.
## The signal drives walk-away behavior in main.gd.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
"D-064: dialogue_dismissed signal must exist on DialogueBox"
).is_true()
box.queue_free()
# ---------------------------------------------------------------------------
# GameState: current_dialogue snapshot parsing
## D-061: current_dialogue is set from snapshot, null when absent.
# ---------------------------------------------------------------------------
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
## apply_snapshot with current_dialogue dict populates the field.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael Davan",
"npc_entity_id": 42,
"speech": "You don't belong here.",
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
},
})
assert_that(GameState.current_dialogue).is_not_null()
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
func test_gamestate_current_dialogue_null_when_absent() -> void:
## apply_snapshot without current_dialogue clears the field.
## Prevents stale dialogue from persisting across ticks.
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
## Non-dict current_dialogue is rejected — defensive against malformed server data.
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
## The options array must survive snapshot parsing for DialogueBox to render them.
var options := [
{"text": "A", "response_id": "r1", "priority": 1},
{"text": "B", "response_id": "r2", "priority": 2},
]
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "NPC",
"npc_entity_id": 1,
"speech": "Choose.",
"options": options,
},
})
assert_that(GameState.current_dialogue).is_not_null()
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
assert_int(parsed_opts.size()).is_equal(2)
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
# ---------------------------------------------------------------------------
# GameState: current_examine_result snapshot parsing (test-first, #174)
## Sprint 18: examine verb returns character-filtered observation text.
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
## GameState must expose current_examine_result for the overlay display node.
# ---------------------------------------------------------------------------
func test_gamestate_examine_result_field_exists() -> void:
## GameState must have a current_examine_result field (Sprint 18, #174).
## Fails until Stig adds the field to game_state.gd.
assert_bool(GameState.has("current_examine_result")).override_failure_message(
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
).is_true()
func test_gamestate_examine_result_null_by_default() -> void:
## current_examine_result defaults to null (no examine active).
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
return
GameState.current_examine_result = null
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_set_from_snapshot() -> void:
## apply_snapshot with examine_result dict populates current_examine_result.
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 5,
"examine_result": {
"entity_id": 12,
"text": "Kael Davan — nervous energy. He's scanning exits.",
"confidence": "KnowsOf",
},
})
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:
## apply_snapshot without examine_result must clear the field.
## Prevents stale examine overlay persisting beyond auto-dismiss window.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
return
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
GameState.apply_snapshot({"tick": 6})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_null_when_non_dict() -> void:
## Malformed examine_result (not a dict) must be rejected.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
return
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:
## entity_id is needed to anchor the overlay above the correct entity.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 1,
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
})
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
# ---------------------------------------------------------------------------
# BBCode injection guard (regression: Hoshe #2)
## Server-sourced text containing BBCode brackets must be escaped.
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
# ---------------------------------------------------------------------------
func test_escape_bbcode_brackets_in_server_text() -> void:
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
## as plain text in the dialogue log.
var box := _make_dialogue_box()
if box == null: return
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
assert_that(escaped).contains("[lb]")
assert_bool(escaped.begins_with("[")).is_false()
box.queue_free()
func test_escape_bbcode_plain_text_unchanged() -> void:
## Non-BBCode text must not be modified by _escape_bbcode.
var box := _make_dialogue_box()
if box == null: return
var plain := "Kael Davan"
assert_that(box._escape_bbcode(plain)).is_equal(plain)
box.queue_free()
func test_escape_bbcode_multiple_brackets() -> void:
## Multiple '[' characters all get escaped.
var box := _make_dialogue_box()
if box == null: return
var text := "[b]Bold[/b] and [i]italic[/i]"
var escaped: String = box._escape_bbcode(text)
assert_bool(escaped.contains("[b]")).is_false()
assert_bool(escaped.contains("[i]")).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# Log dirty flag optimization (regression: Hoshe #1)
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
# ---------------------------------------------------------------------------
func test_log_dirty_false_on_init() -> void:
## _log_dirty starts false — no rebuild needed before first line.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false on init — no unnecessary rebuild"
).is_false()
box.queue_free()
func test_log_dirty_set_after_append_line() -> void:
## Appending a line sets _log_dirty = true.
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hello.", false)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be true after append_line to trigger rebuild next _process"
).is_true()
box.queue_free()
func test_log_dirty_cleared_after_process() -> void:
## After _process(), _log_dirty is cleared (rebuild done).
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "One line.", false)
assert_bool(box._log_dirty).is_true()
box._process(0.0)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false after _process (rebuild consumed the flag)"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-061: Dialogue box size constraints
# ---------------------------------------------------------------------------
func test_d061_max_height_ratio_is_twenty_percent() -> void:
## D-061: dialogue box must occupy max 20% viewport height.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d061_fade_in_is_200ms() -> void:
## D-061: fade-in on dialogue appearance is 0.2s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_IN).override_failure_message(
"D-061: FADE_IN must be 0.2s"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d064_fade_out_is_300ms() -> void:
## D-064: walk-away fade-out is 300ms per spec.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_OUT).override_failure_message(
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
).is_equal_approx(0.3, 0.001)
box.queue_free()
# ---------------------------------------------------------------------------
# Passive log entry (D-078: overheard NPC-NPC)
# ---------------------------------------------------------------------------
func test_passive_entry_uses_bar_glyph() -> void:
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
## Verifies the glyph constant is the correct Unicode bar character.
var box := _make_dialogue_box()
if box == null: return
assert_that(box.PASSIVE_GLYPH).override_failure_message(
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
).is_equal("\u2503 ")
box.queue_free()
func test_passive_entry_appended_and_marked_is_passive() -> void:
## D-078: append_conversation_event creates a passive log entry.
var box := _make_dialogue_box()
if box == null: return
var event := {
"speaker_id": 10, "target_id": 11,
"speaker_name": "Guard A", "target_name": "Guard B",
"occluded_line": "Did you see the detective?",
"speaker_color_index": 0, "target_color_index": 1,
}
box.append_conversation_event(event)
assert_int(box._log_entries.size()).is_equal(1)
assert_bool(box._log_entries[0].is_passive).override_failure_message(
"D-078: overheard entry must be marked is_passive = true"
).is_true()
box.queue_free()
func test_passive_entry_empty_text_not_appended() -> void:
## D-078: Conversation event with empty occluded_line is silently dropped.
var box := _make_dialogue_box()
if box == null: return
box.append_conversation_event({
"speaker_id": 1, "target_id": 2,
"speaker_name": "A", "target_name": "B",
"occluded_line": "",
})
assert_int(box._log_entries.size()).override_failure_message(
"D-078: empty occluded_line must not produce a log entry"
).is_equal(0)
box.queue_free()
# ---------------------------------------------------------------------------
# has_active_entries / is_dialogue_active
# ---------------------------------------------------------------------------
func test_has_active_entries_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_active_entries()).is_false()
box.queue_free()
func test_has_active_entries_true_after_append() -> void:
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hey.", false)
assert_bool(box.has_active_entries()).is_true()
box.queue_free()
func test_is_dialogue_active_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.is_dialogue_active()).is_false()
box.queue_free()
func test_is_dialogue_active_true_after_show_dialogue() -> void:
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(box.is_dialogue_active()).is_true()
box.queue_free()
+298
View File
@@ -0,0 +1,298 @@
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
## Tests for relationship color indicator in interaction_list.gd.
##
## Spec refs:
## D-033 (entity color = relationship to player)
## D-051 (diegetic insert display — insert-only overlay)
## D-057 (interaction list, z-layer 6)
##
## Phase 1 scope (2026-02-24):
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
## server protocol change (no known_attributes in wire protocol v13).
##
## Implementation: client/ui/interaction_list.gd
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
## `func _cache_entity_relationship() -> void`
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
class_name TestETalkOverlaySprint17
extends GdUnitTestSuite
func before_test() -> void:
SimBridge.reset_test_state()
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
func after_test() -> void:
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
# -------------------------------------------------------------------------
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
# -------------------------------------------------------------------------
func test_relationship_unknown_maps_to_teal() -> void:
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_friendly_maps_to_green() -> void:
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_poi_maps_to_amber() -> void:
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_hostile_maps_to_red() -> void:
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_known_falls_back_to_unknown_color() -> void:
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# -------------------------------------------------------------------------
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
# -------------------------------------------------------------------------
func test_ui_strings_has_relationship_unknown_label() -> void:
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
func test_ui_strings_has_relationship_known_label() -> void:
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
func test_ui_strings_has_relationship_friendly_label() -> void:
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
func test_ui_strings_has_relationship_poi_label() -> void:
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
func test_ui_strings_has_relationship_hostile_label() -> void:
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
# -------------------------------------------------------------------------
# _relationship_color initial state
# -------------------------------------------------------------------------
func test_relationship_color_default_is_implant_dim() -> void:
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
var list = _make_list()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
# -------------------------------------------------------------------------
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
# -------------------------------------------------------------------------
func test_cache_relationship_unknown_sets_unknown_color() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_friendly_sets_green() -> void:
_setup_npc_interaction(2, "Friendly")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_cache_relationship_poi_sets_amber() -> void:
_setup_npc_interaction(2, "PersonOfInterest")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
list.queue_free()
func test_cache_relationship_hostile_sets_red() -> void:
_setup_npc_interaction(2, "Hostile")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
GameState.visible_entities = []
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
func test_cache_relationship_updates_when_relationship_changes() -> void:
# First call: Unknown
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# Relationship shifts → re-cache picks up new value
_setup_npc_interaction(2, "Hostile")
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_targets_correct_entity_by_id() -> void:
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
# Entity 3 (Hostile) must not pollute the color
GameState.visible_entities = [
{
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
},
{
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
},
]
GameState.nearby_interactions = [
{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
{
"entity_id": 3, "entity_type": "Npc", "distance": 2,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
]
var list = _make_list()
list.update_from_state()
# interaction[0] = entity 2 (Friendly) → green bar
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_z_layer_is_insert_canvas() -> void:
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
var list = _make_list()
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -------------------------------------------------------------------------
# Regression: interaction_list public API unaffected by #537 changes
# -------------------------------------------------------------------------
func test_list_hides_when_no_interactions() -> void:
GameState.nearby_interactions = []
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_shows_on_npc_interaction() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_true()
list.queue_free()
func test_list_get_selected_verb_returns_first_verb() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
],
}]
var list = _make_list()
assert_that(list.get_selected_verb()).is_equal("Talk")
list.queue_free()
func test_list_get_interaction_target_returns_entity_id() -> void:
GameState.visible_entities = [{
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 5, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.get_interaction_target()).is_equal(5)
list.queue_free()
func test_list_suppressed_when_insert_inactive() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.set_insert_active(false)
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_hides_on_empty_verb_list() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
# -------------------------------------------------------------------------
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
# -------------------------------------------------------------------------
func skip_test_npc_name_displayed_when_known() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
pass
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
func _make_list() -> Control:
var scene = load("res://ui/interaction_list.tscn")
var list = scene.instantiate()
add_child(list) # _ready() fires here — @onready var _vbox resolves
return list
## Set up GameState with a single NPC entity + matching interaction for tests.
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
GameState.visible_entities = [{
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
@@ -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()
+156
View File
@@ -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()
+182
View File
@@ -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"
)
+601
View File
@@ -0,0 +1,601 @@
## Sprint 18 — Knowledge/journal display (#264)
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
## D-042 (UIStrings for all labels)
##
## Tests now run against live Stig implementation.
## Wire format per game_state.gd v14:
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
##
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
## The server sends KG updates only when the graph changes — absence = no change.
## Contrast with current_examine_result which DOES clear each snapshot.
class_name TestJournalSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
func _make_journal_panel() -> Control:
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
return null
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
var base: Dictionary = {
"entity_id": 42,
"name": "Kael Davan",
"confidence": "KnowsOf",
"source": "DirectObservation",
"state": "Active",
"relationship": "PersonOfInterest",
"last_observed_tick": 1024,
}
base.merge(overrides, true)
return base
func _make_player_knowledge(entities: Array = []) -> Dictionary:
if entities.is_empty():
entities = [_make_kg_entity()]
return {"entities": entities}
func _entries_container(panel: Control) -> Node:
return panel.get_node_or_null(
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.current_tick = 0
func after_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
# ---------------------------------------------------------------------------
# GameState: player_knowledge snapshot parsing
# ---------------------------------------------------------------------------
func test_gamestate_player_knowledge_field_exists() -> void:
## GameState must have player_knowledge field (v14, #264).
assert_bool(GameState.has("player_knowledge")).override_failure_message(
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
).is_true()
func test_gamestate_player_knowledge_null_by_default() -> void:
GameState.player_knowledge = null
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 10,
"player_knowledge": _make_player_knowledge(),
})
assert_that(GameState.player_knowledge).is_not_null()
assert_bool(GameState.player_knowledge.has("entities")).is_true()
func test_gamestate_player_knowledge_persists_when_absent() -> void:
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
## Server sends KG updates only on change — absence means "no change since last tick".
## This is intentional behavior (journal should not flash empty every tick).
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 11})
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
## Malformed player_knowledge (non-dict) must be rejected.
## First set a valid value, then try to overwrite with invalid
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
# Non-dict is rejected — previous value preserved (or null if first time)
# The implementation only updates on Dictionary type, so value persists
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
var entities := [
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
]
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(parsed_entities.size()).is_equal(2)
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
## When server sends a new player_knowledge, it replaces the previous value.
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
])})
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
_make_kg_entity({"name": "Person B", "entity_id": 99}),
])})
var entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(entities.size()).is_equal(2)
# ---------------------------------------------------------------------------
# Journal panel: scene and API
# ---------------------------------------------------------------------------
func test_journal_panel_scene_exists() -> void:
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
"Journal panel scene must exist at res://ui/journal_panel.tscn"
).is_true()
func test_journal_panel_instantiates_without_crash() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_that(panel).is_not_null()
panel.queue_free()
func test_journal_panel_has_toggle_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("toggle")).override_failure_message(
"JournalPanel must have toggle() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_close_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("close")).override_failure_message(
"JournalPanel must have close() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_is_open_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("is_open")).override_failure_message(
"JournalPanel must have is_open() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_update_from_state_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("update_from_state")).override_failure_message(
"JournalPanel must have update_from_state() method (called from main.gd)"
).is_true()
panel.queue_free()
func test_journal_panel_closed_on_init() -> void:
## Panel starts hidden — not open by default.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.is_open()).override_failure_message(
"JournalPanel must be closed on _ready()"
).is_false()
panel.queue_free()
func test_journal_panel_toggle_opens() -> void:
## First toggle() opens the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle()
assert_bool(panel.is_open()).override_failure_message(
"toggle() must set is_open() = true"
).is_true()
panel.queue_free()
func test_journal_panel_toggle_closes() -> void:
## Second toggle() closes the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open
panel.toggle() # close
assert_bool(panel.is_open()).override_failure_message(
"Second toggle() must close the panel"
).is_false()
panel.queue_free()
func test_journal_panel_close_when_already_closed_is_safe() -> void:
## close() on an already-closed panel must not crash.
var panel := _make_journal_panel()
if panel == null: return
panel.close()
assert_bool(panel.is_open()).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: entry rendering
# ---------------------------------------------------------------------------
func test_journal_panel_entries_container_exists() -> void:
## EntriesContainer is the VBoxContainer that holds entity entries.
var panel := _make_journal_panel()
if panel == null: return
var container := _entries_container(panel)
assert_that(container != null).override_failure_message(
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
).is_true()
panel.queue_free()
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Kael Davan"}),
])
panel.toggle() # calls _show_panel() -> _rebuild_entries()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer must have children when player_knowledge is populated"
).is_greater(0)
panel.queue_free()
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
## Empty state Label is shown when player_knowledge is null.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = null
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Empty state = exactly 1 child (the "Nothing logged yet." label)
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
).is_equal(1)
panel.queue_free()
func test_journal_panel_two_entities_create_more_entries() -> void:
## Two entities create more entries than one (header + detail each, plus spacers).
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
assert_int(container.get_child_count()).override_failure_message(
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
).is_greater_equal(6)
panel.queue_free()
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
## D-041: Contradicted entities must have strikethrough in their header BBCode.
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## First child should be the header RichTextLabel with [s]...[/s]
if container.get_child_count() == 0:
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_that(first_child.text).override_failure_message(
"Contradicted entity header must contain [s] (strikethrough) BBCode"
).contains("[s]")
panel.queue_free()
func test_journal_panel_active_entity_no_strikethrough() -> void:
## Active entity must NOT have strikethrough in its header.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
if container.get_child_count() == 0:
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_bool(first_child.text.contains("[s]")).override_failure_message(
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: mutual exclusion with dialogue
# ---------------------------------------------------------------------------
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
## Sprint briefing: journal must close when dialogue opens.
## update_from_state() is called from main.gd on each snapshot.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
assert_bool(panel.is_open()).is_true()
GameState.dialogue_active = true
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
).is_false()
panel.queue_free()
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
## update_from_state() must NOT close journal when dialogue is not active.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
GameState.dialogue_active = false
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must stay open when dialogue is inactive"
).is_true()
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
# ---------------------------------------------------------------------------
func test_ui_strings_confidence_direct_exists() -> void:
## D-042: confidence label for "Direct" tier must be in UIStrings.
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsdetails_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsof_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
).is_true()
func test_ui_strings_confidence_suspects_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
).is_true()
func test_ui_strings_all_confidence_keys_non_empty() -> void:
## All four confidence label values must be non-empty strings.
var keys := [
"knowledge_panel.confidence_direct",
"knowledge_panel.confidence_knowsdetails",
"knowledge_panel.confidence_knowsof",
"knowledge_panel.confidence_suspects",
]
for key in keys:
if not UIStrings.has_key(key): continue
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
"UIStrings key '%s' must be non-empty" % key
).is_true()
func test_ui_strings_source_directobservation_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
).is_true()
func test_ui_strings_source_toldby_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
).is_true()
func test_ui_strings_source_heard_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
).is_true()
# ---------------------------------------------------------------------------
# Journal panel: _state_color() contract
# ---------------------------------------------------------------------------
func test_state_color_contradicted_is_amber() -> void:
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Contradicted")
assert_that(color).override_failure_message(
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
).is_equal(Constants.ENTITY_COLOR_POI)
panel.queue_free()
func test_state_color_stale_is_dimmed() -> void:
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Stale")
assert_that(color).override_failure_message(
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
).is_equal(Constants.IMPLANT_TEXT_DIM)
panel.queue_free()
func test_state_color_active_is_normal() -> void:
## Active → normal insert text color (INSERT_COLOR_TEXT).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Active")
assert_that(color).override_failure_message(
"_state_color('Active') must return INSERT_COLOR_TEXT"
).is_equal(Constants.INSERT_COLOR_TEXT)
panel.queue_free()
func test_state_color_contradicted_differs_from_active() -> void:
## Contradicted and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var contradicted := panel._state_color("Contradicted")
var active := panel._state_color("Active")
assert_that(contradicted).is_not_equal(active)
panel.queue_free()
func test_state_color_stale_differs_from_active() -> void:
## Stale and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var stale := panel._state_color("Stale")
var active := panel._state_color("Active")
assert_that(stale).is_not_equal(active)
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: knowledge_panel keys (D-042)
# ---------------------------------------------------------------------------
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
## Journal title uses knowledge_panel.tab_contacts.
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
## Empty state message uses knowledge_panel.empty_state.
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.empty_state"): return
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
# ---------------------------------------------------------------------------
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
## Regression guard: verify the dicts are gone and UIStrings fallback works.
# ---------------------------------------------------------------------------
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
## If this test fails, the hardcoded dict was accidentally re-introduced.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
## UIStrings falls back to the key string itself for missing keys.
## journal_panel.gd relies on this for graceful degradation.
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
assert_that(fallback).override_failure_message(
"UIStrings fallback must return the key string itself for unknown keys"
).is_equal("knowledge_panel.confidence_nonexistent_level")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
func test_canvas_insert_constant_is_10() -> void:
assert_int(Constants.CANVAS_INSERT).is_equal(10)
func test_journal_fade_constants_reasonable() -> void:
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
var panel := _make_journal_panel()
if panel == null: return
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
panel.queue_free()
+320
View File
@@ -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")
+318
View File
@@ -0,0 +1,318 @@
## Sprint 17 — Time display on insert HUD (#263)
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
##
## Spec refs:
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
## D-051 (diegetic insert display)
##
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
##
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
## reading Label.text via public node paths instead.
class_name TestTimeDisplaySprint17
extends GdUnitTestSuite
var _clock: Control = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.game_time = {}
var ClockScript = load("res://ui/time_display.gd")
_clock = Control.new()
_clock.set_script(ClockScript)
add_child(_clock)
func after_test() -> void:
if _clock and is_instance_valid(_clock):
_clock.queue_free()
_clock = null
GameState.game_time = {}
# -------------------------------------------------------------------------
# Constants.format_game_time() — pure logic, D-031
# -------------------------------------------------------------------------
func test_format_midnight() -> void:
assert_that(Constants.format_game_time(0)).is_equal("00:00")
func test_format_morning_start() -> void:
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
assert_that(Constants.format_game_time(360)).is_equal("06:00")
func test_format_noon() -> void:
assert_that(Constants.format_game_time(720)).is_equal("12:00")
func test_format_evening_start() -> void:
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
func test_format_end_of_day() -> void:
# Last valid minute — must not wrap or overflow
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
func test_format_pads_single_digit_hour() -> void:
# 30 min = 00:30
assert_that(Constants.format_game_time(30)).is_equal("00:30")
func test_format_pads_single_digit_minute() -> void:
# 121 min = 02:01
assert_that(Constants.format_game_time(121)).is_equal("02:01")
func test_format_half_past_hour() -> void:
assert_that(Constants.format_game_time(90)).is_equal("01:30")
func test_format_arbitrary_midday() -> void:
# 835 min = 13:55
assert_that(Constants.format_game_time(835)).is_equal("13:55")
# -------------------------------------------------------------------------
# InsertClock initial state
# -------------------------------------------------------------------------
func test_insert_clock_initial_time_str_is_placeholder() -> void:
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
assert_that(_clock._time_str).is_equal("--:--")
func test_insert_clock_initial_phase_str_is_empty() -> void:
assert_that(_clock._phase_str).is_equal("")
func test_insert_clock_initial_day_str_is_empty() -> void:
assert_that(_clock._day_str).is_equal("")
# -------------------------------------------------------------------------
# InsertClock.update_from_state() — reads GameState.game_time
# -------------------------------------------------------------------------
func test_update_from_state_formats_time_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("12:00")
func test_update_from_state_sets_phase_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_str).is_equal("Evening")
func test_update_from_state_sets_day_str_one_indexed() -> void:
# Day 0 from server → "D1" display (1-indexed)
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D1")
func test_update_from_state_day_2() -> void:
GameState.game_time = {
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D2")
func test_update_from_state_midnight() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("00:00")
func test_update_from_state_end_of_day() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("23:59")
func test_update_from_state_skips_empty_game_time() -> void:
# Empty game_time must not overwrite --:-- (guard in update_from_state)
GameState.game_time = {}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("--:--")
func test_update_from_state_deduplicates_same_tick() -> void:
# Calling twice with identical data must produce same result (signature cache)
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
# Call again — result unchanged, no crash
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
func test_update_from_state_updates_on_new_tick() -> void:
# time_of_day changes → signature changes → _time_str updates
GameState.game_time = {
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("01:00")
GameState.game_time = {
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("02:00")
# -------------------------------------------------------------------------
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
# -------------------------------------------------------------------------
func test_phase_colors_has_morning() -> void:
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
func test_phase_colors_has_afternoon() -> void:
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
func test_phase_colors_has_evening() -> void:
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
func test_phase_colors_has_night() -> void:
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
func test_phase_color_applied_after_update() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
# -------------------------------------------------------------------------
# GameState: game_time field parsing (confirms apply_snapshot wiring)
# -------------------------------------------------------------------------
func test_game_time_populated_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 5, "entities": [],
"game_time": {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
func test_game_time_all_four_phases_store_correctly() -> void:
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
func test_game_time_zero_time_of_day_stored() -> void:
# time_of_day = 0 (midnight) must not be treated as falsy/missing
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
# -------------------------------------------------------------------------
# SimBridge test mode: game_time fields are valid
# -------------------------------------------------------------------------
func test_sim_bridge_snapshot_has_game_time() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.has("game_time")).is_true()
assert_that(snap.game_time is Dictionary).is_true()
func test_sim_bridge_game_time_has_required_fields() -> void:
var snap = SimBridge._test_snapshot()
var gt: Dictionary = snap.game_time
assert_that(gt.has("day")).is_true()
assert_that(gt.has("time_of_day")).is_true()
assert_that(gt.has("day_phase")).is_true()
assert_that(gt.has("tick_rate")).is_true()
func test_sim_bridge_time_of_day_is_non_negative() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
# D-031: 1440 game-minutes per day, valid range 0..1439
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
func test_sim_bridge_day_phase_is_valid() -> void:
var snap = SimBridge._test_snapshot()
var phase: String = snap.game_time.get("day_phase", "")
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
# -------------------------------------------------------------------------
# Scene: InsertClock node at InsertOverlay/TimeDisplay
# -------------------------------------------------------------------------
func test_insert_clock_exists_in_ui_layer() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
func test_insert_clock_time_str_updates_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
GameState.apply_snapshot({
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
assert_that(clock).is_not_null()
assert_that(clock._time_str).is_equal("12:00")
# -------------------------------------------------------------------------
# Regression: debug_overlay still reads game_time correctly (#511)
# -------------------------------------------------------------------------
func test_debug_overlay_reads_game_time_day_phase() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
func test_debug_overlay_reads_game_time_tick_rate() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
})
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
+3 -2
View File
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
func test_minimap_placeholder_exists_in_ui_layer() -> void: func test_minimap_placeholder_exists_in_ui_layer() -> void:
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet). # D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
var scene := load("res://scenes/main.tscn") var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate() _instance = scene.instantiate()
auto_free(_instance) auto_free(_instance)
add_child(_instance) add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null() assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
func test_hud_exists_in_ui_layer() -> void: func test_hud_exists_in_ui_layer() -> void:
+99
View File
@@ -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]
+53 -24
View File
@@ -354,26 +354,45 @@ func _show_options(options: Array) -> void:
var raw_text: String = opt.get("text", "") var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false) var is_confrontation: bool = opt.get("confrontation", false)
var label := Label.new() # D-063: Confrontation options render italic — first-person voice, weighted differently.
label.add_theme_font_size_override("font_size", 14) # Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) var ctrl: Control
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART if is_confrontation:
label.mouse_filter = Control.MOUSE_FILTER_STOP var rtl := RichTextLabel.new()
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND rtl.bbcode_enabled = true
rtl.fit_content = true
var numbered_text := "%d. %s" % [i + 1, raw_text] rtl.scroll_active = false
label.text = numbered_text rtl.add_theme_font_size_override("normal_font_size", 14)
rtl.add_theme_color_override("default_color", Color(
Constants.INSERT_COLOR_TEXT.r * 1.08,
Constants.INSERT_COLOR_TEXT.g * 0.96,
Constants.INSERT_COLOR_TEXT.b * 0.90,
1.0
)) # Slight warm tint for confrontation weight
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
ctrl = rtl
else:
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
label.text = "%d. %s" % [i + 1, raw_text]
ctrl = label
var idx := i var idx := i
label.gui_input.connect(func(event: InputEvent): ctrl.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx) _on_option_pressed(idx)
) )
label.mouse_entered.connect(_make_hover_on(label)) ctrl.mouse_entered.connect(_make_hover_on(ctrl))
label.mouse_exited.connect(_make_hover_off(label)) ctrl.mouse_exited.connect(_make_hover_off(ctrl))
options_container.add_child(label) options_container.add_child(ctrl)
_option_controls.append(label) _option_controls.append(ctrl)
_option_response_ids.append(opt.get("response_id", "")) _option_response_ids.append(opt.get("response_id", ""))
_option_texts.append(raw_text) _option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation) _option_is_confrontation.append(is_confrontation)
@@ -519,7 +538,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
## Escape BBCode bracket characters in server-sourced text (Hoshe #2). ## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
static func _escape_bbcode(text: String) -> String: static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]") return text.replace("[", "[lb]").replace("]", "[rb]")
## Get a stable color for a character name, with contrast floor enforcement. ## Get a stable color for a character name, with contrast floor enforcement.
@@ -575,19 +594,22 @@ func _expire_entries() -> void:
var removed := false var removed := false
var has_fading := false var has_fading := false
# Remove expired non-pinned entries from the front (oldest first) # Remove expired non-pinned entries (oldest first, skipping pinned)
while _log_entries.size() > 0: var i := 0
var entry: Dictionary = _log_entries[0] while i < _log_entries.size():
var entry: Dictionary = _log_entries[i]
if entry.pinned: if entry.pinned:
break # Pinned entries never expire i += 1
continue # Pinned entries never expire — keep scanning
var age: int = now - entry.timestamp_msec var age: int = now - entry.timestamp_msec
if age < total_lifetime_msec: if age < total_lifetime_msec:
# Check if this entry is in the fading phase # Check if this entry is in the fading phase
if age > int(_entry_lifetime * 1000.0): if age > int(_entry_lifetime * 1000.0):
has_fading = true has_fading = true
break break
_log_entries.remove_at(0) _log_entries.remove_at(i)
removed = true removed = true
# Don't increment i — element at i is now the next entry
# Check remaining entries for fading state # Check remaining entries for fading state
if not has_fading: if not has_fading:
@@ -623,11 +645,18 @@ func _clear_options() -> void:
# Hover callbacks # Hover callbacks
static func _make_hover_on(label: Control) -> Callable: static func _make_hover_on(ctrl: Control) -> Callable:
return func(): return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER) # RichTextLabel uses "default_color"; Label uses "font_color"
if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
static func _make_hover_off(label: Control) -> Callable: static func _make_hover_off(ctrl: Control) -> Callable:
return func(): return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
+88
View File
@@ -0,0 +1,88 @@
extends Control
## Examine result display — #174, D-061 adjacent.
##
## Shows the character-filtered text returned by the Examine verb (#242).
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
## Diegetic: reads as the neural insert processing what the character observed.
##
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
## Only one examine result is shown at a time — new result replaces old.
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.35
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
const CONFIDENCE_ALPHA: Dictionary = {
"Direct": 1.0,
"KnowsDetails": 0.9,
"KnowsOf": 0.75,
"Suspects": 0.6,
}
@onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
var _dismiss_tween: Tween = null
var _active: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
modulate.a = 0.0
visible = false
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
## result: {entity_id, text, confidence}
func show_result(result: Dictionary) -> void:
var text: String = result.get("text", "")
var confidence: String = result.get("confidence", "KnowsOf")
if text.is_empty():
return
# Cancel any in-progress dismiss
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
# Apply confidence-based alpha to the insert color
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
Constants.INSERT_COLOR_TEXT.b, alpha)
text_label.add_theme_color_override("default_color", col)
text_label.text = text
visible = true
_active = true
modulate.a = 0.0
# Fade in, then auto-dismiss
_dismiss_tween = create_tween()
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
_dismiss_tween.tween_interval(DISMISS_DELAY)
_dismiss_tween.tween_callback(_start_fade_out)
func _start_fade_out() -> void:
if not _active:
return
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func():
visible = false
_active = false
)
## Dismiss immediately (e.g. when dialogue opens).
func dismiss() -> void:
if not _active:
return
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
func is_active() -> bool:
return _active
+50
View File
@@ -0,0 +1,50 @@
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
[node name="ExamineDisplay" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 0.0
offset_left = -440.0
offset_top = 120.0
offset_right = -16.0
offset_bottom = 240.0
grow_horizontal = 0
grow_vertical = 2
mouse_filter = 2
modulate = Color(1, 1, 1, 0)
script = ExtResource("1_examine")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
mouse_filter = 2
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
fit_content = true
scroll_active = false
mouse_filter = 2
theme_override_font_sizes/normal_font_size = 13
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)
+26 -1
View File
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0 var _selected_index: int = 0
var _active_tween: Tween = null var _active_tween: Tween = null
var _verb_labels: Array[Label] = [] var _verb_labels: Array[Label] = []
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
@onready var _vbox: VBoxContainer = $VBox @onready var _vbox: VBoxContainer = $VBox
@@ -39,10 +41,14 @@ func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
var _last_screen_pos: Vector2 = Vector2.ZERO
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _showing: if _showing:
_update_screen_position() _update_screen_position()
queue_redraw() if position != _last_screen_pos:
_last_screen_pos = position
queue_redraw()
func _draw() -> void: func _draw() -> void:
@@ -52,6 +58,9 @@ func _draw() -> void:
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2) var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
draw_rect(bg_rect, INSERT_BG) draw_rect(bg_rect, INSERT_BG)
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0) draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
func update_from_state() -> void: func update_from_state() -> void:
@@ -88,6 +97,7 @@ func update_from_state() -> void:
_verb_items = sorted _verb_items = sorted
_selected_index = 0 _selected_index = 0
_cache_entity_position() _cache_entity_position()
_cache_entity_relationship()
_rebuild_labels() _rebuild_labels()
_show() _show()
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
return return
## #537: Cache relationship color for the target entity (D-033 palette).
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
func _cache_entity_relationship() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
var rel: String = str(entity.get("relationship", "Unknown"))
_relationship_color = Constants.color_for_relationship(rel)
return
_relationship_color = Constants.IMPLANT_TEXT_DIM
## Convert entity world position to screen coords and reposition this Control. ## Convert entity world position to screen coords and reposition this Control.
## Runs every frame while showing so the list tracks the entity as the camera moves. ## Runs every frame while showing so the list tracks the entity as the camera moves.
func _update_screen_position() -> void: func _update_screen_position() -> void:
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
return _verb_items.size() return _verb_items.size()
func hide_list() -> void:
_hide()
func is_showing() -> bool: func is_showing() -> bool:
return _showing return _showing
+214
View File
@@ -0,0 +1,214 @@
extends Control
## Journal panel — knowledge graph review. #264, D-041.
##
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
## grouped by entity. Read-only — no player interaction beyond scrolling.
##
## KnowledgeState rendering:
## Active → normal color (INSERT_COLOR_TEXT)
## Stale → dimmed (IMPLANT_TEXT_DIM)
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
##
## KnowledgeConfidence labels (D-041):
## Direct → KnowsDetails → KnowsOf → Suspects
##
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
## Closes when dialogue opens.
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.25
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
# Fallback: raw value if key not found (UIStrings returns the key itself).
@onready var panel: PanelContainer = $PanelContainer
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
var _visible_state: bool = false
var _last_rendered_tick: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
modulate.a = 0.0
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
## Toggle open/close. Called from main.gd on J key press.
func toggle() -> void:
if _visible_state:
_hide_panel()
else:
_show_panel()
## Force-close. Called when dialogue opens.
func close() -> void:
if _visible_state:
_hide_panel()
func is_open() -> bool:
return _visible_state
func _show_panel() -> void:
_rebuild_entries()
_visible_state = true
visible = true
var t := create_tween()
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide_panel() -> void:
_visible_state = false
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
## Rebuild the entry list from GameState.player_knowledge.
func _rebuild_entries() -> void:
# Clear previous entries
for child in entries_container.get_children():
child.queue_free()
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
_add_empty_state()
return
var entities: Array = knowledge.get("entities", [])
if entities.is_empty():
_add_empty_state()
return
for entity in entities:
if not entity is Dictionary:
continue
_add_entity_entry(entity)
func _add_empty_state() -> void:
var label := Label.new()
label.text = UIStrings.get_text("knowledge_panel.empty_state")
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
label.add_theme_font_size_override("font_size", 13)
entries_container.add_child(label)
func _add_entity_entry(entity: Dictionary) -> void:
var name_str: String = entity.get("name", "Unknown")
var confidence: String = entity.get("confidence", "Suspects")
var source: String = entity.get("source", "")
var state: String = entity.get("state", "Active")
var relationship: String = entity.get("relationship", "Unknown")
# Entity header — name + relationship status
var header_rtl := RichTextLabel.new()
header_rtl.bbcode_enabled = true
header_rtl.fit_content = true
header_rtl.scroll_active = false
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
header_rtl.add_theme_font_size_override("normal_font_size", 14)
var rel_color: Color = Constants.color_for_relationship(relationship)
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
rel_label = relationship # fallback if key missing
var state_color: Color = _state_color(state)
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
var src_label: String = _resolve_source_label(source)
# Build BBCode:
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
var name_hex: String = state_color.to_html(false)
var rel_hex: String = rel_color.to_html(false)
var header_bbcode: String
if state == "Contradicted":
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
name_hex, name_str, rel_hex, rel_label
]
else:
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
name_hex, name_str, rel_hex, rel_label
]
header_rtl.text = header_bbcode
entries_container.add_child(header_rtl)
# Detail line — confidence + source
var detail_rtl := RichTextLabel.new()
detail_rtl.bbcode_enabled = true
detail_rtl.fit_content = true
detail_rtl.scroll_active = false
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
entries_container.add_child(detail_rtl)
# Spacer between entries
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
entries_container.add_child(spacer)
## Resolve a source string from the server into a human-readable label.
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
func _resolve_source_label(source: String) -> String:
if source.is_empty():
return ""
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
if source.begins_with("ToldBy(") and source.ends_with(")"):
var id_str: String = source.substr(7, source.length() - 8)
var entity_id: int = id_str.to_int()
var name := _entity_name_for_id(entity_id)
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
if told_prefix == "knowledge_panel.source_toldby":
told_prefix = "Told"
return "%s: %s" % [told_prefix, name]
# Plain source key — look up UIStrings
var key: String = "knowledge_panel.source_%s" % source.to_lower()
return UIStrings.get_text(key)
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
func _entity_name_for_id(entity_id: int) -> String:
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
return "#%d" % entity_id
for entity in knowledge.get("entities", []):
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
return entity.get("name", "#%d" % entity_id)
return "#%d" % entity_id
func _state_color(state: String) -> Color:
match state:
"Contradicted":
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
"Stale":
return Constants.IMPLANT_TEXT_DIM # dimmed
_:
return Constants.INSERT_COLOR_TEXT # active — normal
## Called from main.gd on each snapshot to close the panel when dialogue opens.
func update_from_state() -> void:
if _visible_state and GameState.dialogue_active:
close()
# Rebuild if open and knowledge data is newer than last render
if _visible_state and GameState.current_tick != _last_rendered_tick:
_last_rendered_tick = GameState.current_tick
_rebuild_entries()
+63
View File
@@ -0,0 +1,63 @@
[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"]
[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"]
; JournalPanel — knowledge graph review. #264, D-041.
; Toggle J key. Right side, 380px wide, full height minus margins.
; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue.
[node name="JournalPanel" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -400.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 0
grow_vertical = 2
mouse_filter = 2
modulate = Color(1, 1, 1, 0)
script = ExtResource("1_journal")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 14
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 14
theme_override_constants/margin_bottom = 12
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Contacts"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9)
[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2)
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 2
+53
View File
@@ -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()
+166 -9
View File
@@ -1,15 +1,172 @@
class_name MinimapRenderer
extends Control extends Control
# Minimap — small viewport showing local area ## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
# Placeholder implementation for now ##
## Player always centered. Fixed-north (no rotation per D-015).
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
## Frame renders always — the insert is on even when no POIs are discovered.
##
## POI categories → shapes:
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
## (default) → circle (INSERT_COLOR_TEXT)
@onready var viewport_container: SubViewportContainer = $SubViewportContainer ## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
const MINIMAP_RADIUS: float = 24.0
# Visual parameters
const FRAME_WIDTH: float = 1.2
const PLAYER_DOT_RADIUS: float = 3.5
const POI_DOT_RADIUS: float = 3.0
const ARROW_HALF: float = 4.5
const ARROW_LEN: float = 7.0
const NORTH_TICK_LEN: float = 8.0
const CARDINAL_TICK_LEN: float = 4.0
# Colors — insert palette from Constants, tuned for the circular minimap frame
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
var _insert_active: bool = true
func _ready() -> void: func _ready() -> void:
print("Minimap: Initialized") mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
# Update minimap view (stub) func _process(_delta: float) -> void:
func update_minimap(player_pos: Vector2, entities: Array) -> void: if _insert_active:
# TODO: Render minimap view queue_redraw()
# This will show a top-down view of the local area
pass ## Called from main.gd when GameState.insert_active changes.
## Hides the minimap overlay when the neural insert is inactive.
func set_insert_active(active: bool) -> void:
_insert_active = active
visible = active
func _draw() -> void:
var sz: Vector2 = get_rect().size
var center := sz / 2.0
# Outer radius: fill control with 1px edge padding
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
# --- Background fill ---
draw_circle(center, outer_r, COLOR_BG)
# --- Frame ring ---
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
_draw_cardinal_ticks(center, outer_r)
# --- Player dot at center ---
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
# Soft bloom ring
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
# --- POIs ---
var pois: Array = GameState.discovered_pois
if pois.is_empty():
return
var px: float = GameState.player_position.x
var py: float = GameState.player_position.y
# Pixels per sim tile within the inner drawable area
var inner_r: float = outer_r - FRAME_WIDTH
var scale: float = inner_r / MINIMAP_RADIUS
for poi in pois:
if not poi is Dictionary:
continue
if not poi.has("x") or not poi.has("y"):
continue
var dx: float = float(poi.x) - px
var dy: float = float(poi.y) - py
var dist: float = sqrt(dx * dx + dy * dy)
var category: String = poi.get("poi_category", "")
var color: Color = _category_color(category)
if dist < 0.01:
# POI at exact player position — draw at center offset slightly
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
elif dist <= MINIMAP_RADIUS:
# Nearby: project to screen position within circle
var poi_screen := center + Vector2(dx, dy) * scale
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
var rel := poi_screen - center
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
_draw_poi_shape(poi_screen, color, category)
else:
# Distant: arrow at border pointing toward POI direction
var dir := Vector2(dx, dy).normalized()
var arrow_tip := center + dir * (inner_r - 2.0)
_draw_border_arrow(arrow_tip, dir, color)
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
# North tick — longer, brighter, the fixed-north indicator
var n_dir := Vector2(0.0, -1.0)
draw_line(
center + n_dir * (outer_r - NORTH_TICK_LEN),
center + n_dir * outer_r,
COLOR_NORTH, FRAME_WIDTH + 0.5, true
)
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
var dir := Vector2(cos(angle), sin(angle))
draw_line(
center + dir * (outer_r - CARDINAL_TICK_LEN),
center + dir * outer_r,
COLOR_CARDINAL, FRAME_WIDTH, true
)
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
match category.to_lower():
"danger", "threat", "hostile":
# Diamond for danger
var s: float = POI_DOT_RADIUS + 1.0
draw_polygon(
PackedVector2Array([
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
]),
PackedColorArray([color, color, color, color])
)
"evidence", "note", "clue":
# Square for evidence/clue
var s: float = POI_DOT_RADIUS - 0.5
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
_:
draw_circle(pos, POI_DOT_RADIUS, color)
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := tip - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
func _category_color(category: String) -> Color:
match category.to_lower():
"danger", "threat", "hostile":
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
"evidence", "note", "clue":
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
"contact", "npc", "person":
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
_:
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
+11 -23
View File
@@ -2,33 +2,21 @@
[ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"] [ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"]
[node name="Minimap" type="Control"] ; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6).
; 160x160px circle, top-right corner with 16px margin.
; Rendered via _draw() — no SubViewport needed.
; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn.
[node name="MinimapRenderer" type="Control"]
custom_minimum_size = Vector2(160, 160)
layout_mode = 3 layout_mode = 3
anchors_preset = 1 anchors_preset = 1
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
offset_left = -200.0 offset_left = -176.0
offset_bottom = 200.0 offset_top = 16.0
offset_right = -16.0
offset_bottom = 176.0
grow_horizontal = 0 grow_horizontal = 0
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_minimap") script = ExtResource("1_minimap")
[node name="SubViewportContainer" type="SubViewportContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"]
size = Vector2i(200, 200)
render_target_update_mode = 4
[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.1, 0.1, 0.1, 0.7)
+55 -1
View File
@@ -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)
+13
View File
@@ -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"
+97
View File
@@ -0,0 +1,97 @@
extends Control
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
## Shows station local time (HH:MM), day phase, and day number.
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
##
## Placeholder layout — position and style will be refined when #314 wireframe lands.
const FONT_SIZE_TIME: int = 15
const FONT_SIZE_META: int = 10
const PADDING := Vector2(10, 7)
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
# Day phase colors — station lighting cycle (D-031)
const PHASE_COLORS := {
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
"Night": Color("#4a7080"), # dark teal — station nightwatch
}
var _time_str: String = "--:--"
var _phase_str: String = ""
var _day_str: String = ""
var _day_text: String = ""
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
var _last_signature: String = ""
var _has_data: bool = false
# Cached geometry — recomputed in update_from_state(), used in _draw()
var _time_size: Vector2 = Vector2.ZERO
var _phase_size: Vector2 = Vector2.ZERO
var _day_size: Vector2 = Vector2.ZERO
var _meta_h: float = 0.0
var _box_w: float = 0.0
var _box_h: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var gt: Dictionary = GameState.game_time
if gt.is_empty():
return
var tod: int = int(gt.get("time_of_day", 0))
var day: int = int(gt.get("day", 0))
var phase: String = str(gt.get("day_phase", ""))
var sig: String = "%d:%d:%s" % [tod, day, phase]
if sig == _last_signature:
return
_last_signature = sig
_time_str = Constants.format_game_time(tod)
_phase_str = phase
_day_str = "D%d" % (day + 1)
_day_text = " " + _day_str
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
_has_data = true
_cache_geometry()
queue_redraw()
func _cache_geometry() -> void:
var font := ThemeDB.fallback_font
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
func _draw() -> void:
if not _has_data:
return
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
var font := ThemeDB.fallback_font
# HH:MM (primary, full brightness)
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
# Phase + day number (secondary, dimmed + phase-tinted)
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
draw_string(font, Vector2(PADDING.x, meta_y),
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
+22
View File
@@ -0,0 +1,22 @@
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
; #263: Time display — top-left placeholder per D-013/D-051.
; Position and size will be refined when #314 wireframe lands.
; NOTE: draw-based content manages its own layout; Control rect is a
; minimum bounding box, not a clip rect. Increase if content grows.
[node name="TimeDisplay" type="Control"]
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 16.0
offset_top = 16.0
offset_right = 200.0
offset_bottom = 70.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_tdisplay")
@@ -0,0 +1,236 @@
character: detective
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
#
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
#
# Phase 2 lines: trust established but not deep — the blindsiding.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_001
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
natural in a surprise moment even for the analytical detective — the observation breaks
through the professional register. No interpretation, no accusation.
- id: pc-detective_m_d_con_002
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Alternate primary — fully analytical register: surname-initial for both (institutional
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
detective's version of stating contradiction — clinical, process-language. Fires as
alternate to con_001 for variety on replay.
- id: pc-detective_m_d_con_003
text: "She placed Kael at the dock. He's not at the dock."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2]
notes: >
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
of contradiction. The brevity is the weight: six words and the investigation changes shape.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_004
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [anxious, focused]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
the key line — it holds accusation off even against evidence. The detective files it open.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-detective_m_d_con_005
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
information — the detective separates these without assuming either. 'That's a different
question' closes the processing loop, marking it unresolved, not dismissed.
# -----------------------------------------------------------------------
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_006
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
notes: >
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
for the friend relationship — vs. the clinical observation about Lintar.
- id: pc-detective_m_d_con_007
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
notes: >
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
without concluding. The numbered format ('Two facts... One new observation') is the
detective's internal case-building style. Fully non-accusatory: the connection is not
stated, only the intent to look for it.
- id: pc-detective_m_d_con_008
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
notes: >
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
the detective naming Sera's avoidance behavior — behavioral description, not motive
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
remains unstated. Detective is following the thread, not announcing what's on it.
@@ -0,0 +1,243 @@
character: detective
location: tutorial
lines:
# Diegetic Tutorial Monologue — Ticket #330 — Detective
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
#
# These lines teach mechanics through character voice — not UI instructions.
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
# These fire on first-time events throughout gameplay, wherever they occur.
#
# Coverage:
# Movement / exploration — first_move, first_new_section
# Fog of perception — first_fog_encounter, first_fog_edge
# Sound model — first_sound_heard, first_off_screen_sound
# NPC interaction — first_npc_proximity, first_npc_face_read
# Insert / HUD — first_insert_open, first_insert_contact_flag
#
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
# ID discriminator: _tut_ per sprint briefing.
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
# fire once per game), not by a cooldown value.
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
#
# Schema corrections (Gestalt review, Sprint 17):
# priority: string "tutorial" → integer. All other monologue files use integer priority;
# string value would fail engine parse in monologue.rs.
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
# enum. Replaced with arrival, routine per actual trigger context.
#
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
# D-035 v0.1 set — require server-side implementation in monologue.rs.
# -----------------------------------------------------------------------
# MOVEMENT / EXPLORATION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_001
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_move
mood: [focused]
priority: 8
tags: [tutorial, movement, navigation, analytical]
notes: >
Detective version of the 'check the signage' tutorial. Colon usage for the signage
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
framing; he reads the environment through his professional lens first. 'Worth learning
before the district learns me' — dry awareness of his asymmetric visibility in this
community. Same teaching as the smuggler version, different cognitive frame.
- id: pc-detective_m_d_tut_002
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
role: player_character
access: [public]
trust: surface
situation: [arrival, investigation]
trigger: first_new_section
mood: [focused]
priority: 6
tags: [tutorial, movement, navigation, analytical]
notes: >
Fires on entering a new section for the first time. 'Commission briefing had the
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
principle: documentation is a starting point, not a substitute for observation.
Teaches: the district has distinct sections to explore.
# -----------------------------------------------------------------------
# FOG OF PERCEPTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_003
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_encounter
mood: [focused]
priority: 8
tags: [tutorial, fog, perception, analytical]
notes: >
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
the detective categorizes it as an information state, not a barrier. Compare to
smuggler's 'Might be worth checking' — same fog, different response.
- id: pc-detective_m_d_tut_004
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_edge
mood: [focused]
priority: 6
tags: [tutorial, fog, perception, analytical]
notes: >
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
procedure' — detective frames the limitation as methodology. The second clause
('not a limitation, a working condition') is self-instruction: he's telling himself
not to treat fog as frustrating but as standard operating environment. Teaches:
perception range is persistent throughout gameplay, and the correct response is
movement, not waiting.
# -----------------------------------------------------------------------
# SOUND MODEL
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_005
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_sound_heard
mood: [focused]
priority: 8
tags: [tutorial, sound, perception, analytical]
notes: >
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
and what he can't (count, content). No contractions. Teaches: sound channel is active
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
reality, systematized differently.
- id: pc-detective_m_d_tut_006
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_off_screen_sound
mood: [focused]
priority: 6
tags: [tutorial, sound, perception, analytical]
notes: >
Second sound tutorial — fires on an off-screen audio source. The detective parses
the sound through multiple analytical dimensions: surface type, count, direction,
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
signature' is technical terminology applied to diegetic audio interpretation.
Teaches: sound carries more than presence — floor type, distance, count, direction
are all extractable. The detective extracts all of them automatically.
# -----------------------------------------------------------------------
# NPC INTERACTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_007
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_proximity
mood: [focused]
priority: 8
tags: [tutorial, npc, interaction, analytical]
notes: >
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
his categorization (Commission-trained: everyone is a witness category until proven
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
is the investigative principle: don't prime witnesses, let them self-select information.
No contractions throughout. Teaches: NPC interaction is player-initiated and
approach-sensitive. Different method than the smuggler ('give them reason to') —
same outcome, different social model.
- id: pc-detective_m_d_tut_008
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
role: player_character
access: [public]
trust: surface
situation: [social, investigation]
trigger: first_npc_face_read
mood: [focused]
priority: 6
tags: [tutorial, npc, observation, analytical, authority]
notes: >
Fires when the player observes an NPC at close range. The detective's asymmetry:
his authority access tier makes him visible in ways the smuggler is not. NPCs react
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
the detective's Commission credentials affect NPC behavior, and that behavioral
response is data.
# -----------------------------------------------------------------------
# INSERT / HUD
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_009
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
role: player_character
access: [public]
trust: surface
situation: [routine]
trigger: first_insert_open
mood: [focused]
priority: 8
tags: [tutorial, insert, hud, analytical]
notes: >
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
linked' — institutional framing, establishes why the detective's HUD is denser than
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
show the time and nearby contacts' — same overlay, presented as procedural requirement
vs. operational check.
- id: pc-detective_m_d_tut_010
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
role: player_character
access: [public]
trust: surface
situation: [investigation, routine]
trigger: first_insert_contact_flag
mood: [focused]
priority: 6
tags: [tutorial, insert, hud, lattice, investigation]
notes: >
Fires when the insert first populates with flagged entities. Count given precisely
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
insert is investigation-keyed, matching observed faces against case file entries.
This is why his overlay is analytically denser than the smuggler's.
@@ -0,0 +1,228 @@
character: smuggler
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
#
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
# gut reaction, not analytical procedure.
#
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
# shortened interactions, early exits from break room.
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_001
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
- id: pc-smuggler_m_s_con_002
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
recognition first, then the wrong details fall into place. Each fragment adds one layer
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
expectation, which is now violated. No contraction in the closing declarative (emphasis).
- id: pc-smuggler_m_s_con_003
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2]
notes: >
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
Epistemic containment in plain language.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_004
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
gives Kael benefit of the doubt, then immediately walks it back with operational
logic. 'There's always a reason' is the street-smart equivalent of epistemic
containment — she's seen strange things before. The closing line is where the alarm
lives: this isn't strange, it's a protocol violation.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-smuggler_m_s_con_005
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
understatement. Operational logic applied to an emotional moment: the smuggler
keeps the processing clean even when the stakes are personal.
# -----------------------------------------------------------------------
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_006
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
notes: >
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
catalog builds the picture: three days of signals, then this. Closing line names the
conclusion without naming its implication: 'working an angle' is street-smart language
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
she doesn't know that yet.
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
or simplify to a different check.
- id: pc-smuggler_m_s_con_007
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, operational]
notes: >
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
means either Devra is running something Kael's involved in (plausible, alarming) or
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
good' — the smuggler's understated close for a significant threat read.
- id: pc-smuggler_m_s_con_008
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [anxious]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, intuition]
notes: >
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
describes the behavioral changes without cataloging them. 'Now I know why' — the click
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
out' is street-smart language for exit attempt: the smuggler doesn't know the details
but the read is right. This line is the closest the smuggler comes to naming what's
happening — and she still doesn't say it explicitly.
@@ -0,0 +1,229 @@
character: smuggler
location: tutorial
lines:
# Diegetic Tutorial Monologue — Ticket #330 — Smuggler
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
#
# These lines teach mechanics through character voice — not UI instructions.
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
# These fire on first-time events throughout gameplay, wherever they occur.
#
# Coverage:
# Movement / exploration — first_move, first_new_section
# Fog of perception — first_fog_encounter, first_fog_edge
# Sound model — first_sound_heard, first_off_screen_sound
# NPC interaction — first_npc_proximity, first_npc_face_read
# Insert / HUD — first_insert_open, first_insert_contact_flag
#
# Voice: observational, street-smart, practical. Fragments. Standalone "Good." as tic.
# No analytical distance — the smuggler reads, reacts, files, moves on.
# ID discriminator: _tut_ per sprint briefing.
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
# fire once per game), not by a cooldown value.
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
#
# Schema corrections (Gestalt review, Sprint 17):
# priority: string "tutorial" → integer. All other monologue files use integer priority;
# string value would fail engine parse in monologue.rs.
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
# enum. Replaced with arrival, routine, observation per actual trigger context.
# mood: [focused] retained where set; valid per D-035 Sprint 14 amendment.
#
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
# D-035 v0.1 set — require server-side implementation in monologue.rs.
# -----------------------------------------------------------------------
# MOVEMENT / EXPLORATION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_001
text: "These corridors all look the same. Mental note: check the signage."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_move
mood: [focused]
priority: 8
tags: [tutorial, movement, navigation]
notes: >
Directly from the sprint briefing example. First move teaches navigation: the district
looks homogeneous, signage is the tool. 'Mental note' is the smuggler's internal
flag for things to remember — operational housekeeping, not analysis. Establishes
early: this environment requires active spatial tracking.
- id: pc-smuggler_m_s_tut_002
text: "Two lefts past the junction, right at the supply hatch. That's dock four. Route's in the muscle now."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_new_section
mood: [content]
priority: 6
tags: [tutorial, movement, navigation, operational]
notes: >
Fires on entering a new section for the first time. 'Route's in the muscle' — the
smuggler doesn't memorize spatially, she moves until it's automatic. Practical,
self-sufficient. Teaches: the district has multiple sections, each requires its own
orientation. 'Dock four' is a landmark. Forward movement is rewarded with familiarity.
# -----------------------------------------------------------------------
# FOG OF PERCEPTION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_003
text: "Can't see past that corner. Might be worth checking."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_encounter
mood: [focused]
priority: 8
tags: [tutorial, fog, perception]
notes: >
Directly from the sprint briefing example. First fog boundary. Restated as habit:
the smuggler doesn't treat the fog as a limitation, she treats every unknown corner
as a question worth answering. 'Might be worth' — not paranoia, just operational
instinct. Teaches: the perception boundary is navigable, not fixed.
- id: pc-smuggler_m_s_tut_004
text: "Haze cuts off past the junction. Anything in it — can't say."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_edge
mood: [focused]
priority: 6
tags: [tutorial, fog, perception]
notes: >
Second fog encounter line — fires when the player reaches the boundary of a fog zone
(the edge of visible range). 'Can't say' — plain statement of unknowing, not anxiety.
The smuggler accepts incomplete information as a working condition, not a failure state.
Teaches: perception range is an ongoing factor, not a one-time obstacle.
# -----------------------------------------------------------------------
# SOUND MODEL
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_005
text: "Voices down the hall. Can't make out the words from here."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_sound_heard
mood: [focused]
priority: 8
tags: [tutorial, sound, perception]
notes: >
Directly from the sprint briefing example. First sound heard that's not immediately
visible. Teaching: sound carries information, but range and occlusion limit it.
'Can't make out the words' establishes both the value of the sound channel (voices =
people = relevant) and its limitation (unclear at range). Moving toward source is
the implied next step.
- id: pc-smuggler_m_s_tut_006
text: "Footsteps. Two sets. Moving away — good."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_off_screen_sound
mood: [content]
priority: 6
tags: [tutorial, sound, perception, operational]
notes: >
Second sound line — fires when audio from an off-screen source is detected. The
smuggler parses footsteps as count and direction instinctively. 'Moving away — good.'
— the standalone 'Good.' tic applied to tactical assessment. Teaches: sound carries
directional and movement data, not just presence. Two sets = two people, which is
specific enough to be operationally useful.
# -----------------------------------------------------------------------
# NPC INTERACTION
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_007
text: "Could ask around. People talk if you give them reason to."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_proximity
mood: [content]
priority: 8
tags: [tutorial, npc, interaction, social]
notes: >
Directly from the sprint briefing example. First time in range of an NPC. The smuggler
doesn't approach directly — she flags the option. 'Give them reason to' is street-smart
social knowledge: people don't volunteer freely, they need a hook. Teaches: NPC
interaction is player-initiated, and approach matters. No mechanics text — just the
character's own method stated as personal principle.
- id: pc-smuggler_m_s_tut_008
text: "Know the face. Don't need the name."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_face_read
mood: [content]
priority: 6
tags: [tutorial, npc, observation, social]
notes: >
Fires when the player observes an NPC at close range (face visible). The smuggler
reads people by face, not by name — the name comes later if relevant. Teaches: NPCs
are identifiable by observation, and the insert overlay will fill in names when
available. The smuggler's social intelligence is pre-lattice, instinctive.
# -----------------------------------------------------------------------
# INSERT / HUD
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_tut_009
text: "Check the overlay. Should show the time and nearby contacts."
role: player_character
access: [public]
trust: surface
situation: [routine]
trigger: first_insert_open
mood: [content]
priority: 8
tags: [tutorial, insert, hud, operational]
notes: >
Directly from the sprint briefing example. First insert/HUD open. The smuggler's
overlay shows time and nearby contacts — both operationally critical. 'Should show'
— practical, not certain. Teaches: the insert is the primary tool for time-tracking
and NPC awareness. Diegetic: she checks it the same way she checks a shift board.
- id: pc-smuggler_m_s_tut_010
text: "Overlay marks Kael two sections over. Good."
role: player_character
access: [public]
trust: surface
situation: [routine, social]
trigger: first_insert_contact_flag
mood: [content, warm]
priority: 6
tags: [tutorial, insert, hud, kael, friend-arc]
notes: >
Fires when the insert first flags a known contact (here: Kael, the FRIEND NPC).
'Two sections over' — spatial data from the overlay. 'Good.' standalone tic —
warm, settling. Teaches: the insert shows known NPC positions in real-time.
Also seeds the Kael relationship: the smuggler checks his location as a habit,
not a task. His position is always relevant to her.
+106
View File
@@ -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."
+718
View File
@@ -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
+8
View File
@@ -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" "$@"
+131 -8
View File
@@ -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
View File
@@ -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
+25 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+606
View File
@@ -0,0 +1,606 @@
# Insert/HUD Wireframe and Visual Specification — v0.1
## Dual Character Variants: Smuggler + Detective
**Ticket:** #314
**Author:** Araminta (Visual Designer)
**Date:** 2026-02-24
**Status:** Active — feeds #263 (time display client implementation) and #151 (minimap rendering)
**Visual grammar reference:** `docs/design/visual-grammar-v01.md` (§7 Neural Insert Overlay)
---
## 1. System Architecture
The insert HUD is not a UI panel bolted onto the game. It is the player character's **neural lattice** — Settled Reach technology that overlays computational data onto the character's perception. Every element on z-layer 6 is diegetic: the character is experiencing this information through their implant, not reading a game interface.
This has direct design consequences:
- The insert does not appear/disappear — it activates and deactivates. The character's lattice is always there; they choose when to engage it.
- Labels use the character's vocabulary, not the game's. The smuggler's insert calls it "The Last Shift." The detective's insert calls it "Licensed Establishment, District 7."
- The same underlying `ObserverSnapshot` feeds both variants. One parameterized system: `lattice_profile` on the snapshot determines which visual mode renders. Stig builds one HUD framework, not two. The variants are rendering configurations, not separate systems.
### 1.1 Technical Parameters
| Parameter | Smuggler | Detective |
|-----------|----------|-----------|
| `lattice_profile` | `lattice_baseline` | `lattice_augmented` |
| Line weight | 1px | 1–2px |
| Annotation density | Sparse — essential markers only | Dense — more markers, connection lines |
| Passive opacity | 60% | 80% |
| Active opacity | 85% | 100% |
| Bloom | 2–3px gaussian at 40% blend | 2–3px gaussian at 40% blend |
| Chrome color | `#c8d0e0` | `#c8d0e0` |
### 1.2 Z-Layer Position
Insert HUD lives entirely on **z-layer 6**. It renders above fog (z-layer 5) and below monologue/UI (z-layer 7). The fog shader does not mask it — insert data is computational, not perceptual. Entity markers can appear in fogged areas if the lattice has position data.
---
## 2. Screen Layout
**Base resolution:** 1920×1080px
The HUD has four zones:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ [STANCE] [PERCEPTION] [TIME] │ ← HUD chrome band (y: 0–80px)
│ │
│ │
│ WORLD VIEW │
│ (insert entity markers rendered here, │
│ positioned at entity world locations) │
│ │
│ ↑ ↑ ↑ ↑ border arrows appear here │
│ ← (for off-screen known POIs) → │
│ ↓ ↓ ↓ ↓ │
│ │
│ [INVENTORY] [INVENTORY] │ ← inventory band (y: 816–864px)
├─────────────────────────────────────────────────────────────────────────────┤
│ DIALOGUE BOX (z-layer 7, when active) │ ← bottom 20% (y: 864–1080px)
│ [NPC speech] │
│ [response options] │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Coordinate system:** Origin (0,0) is top-left. X increases rightward, Y increases downward. All pixel values at 1920×1080 base; Godot 4 DPI scaling handles other resolutions.
**Dialogue box:** The bottom 20% (216px) is reserved when dialogue is active. Insert overlay remains visible at 60% of its normal opacity when dialogue is open — the lattice keeps running, the character keeps perceiving. The insert does not suppress during dialogue.
---
## 3. Time Display
**Feeds:** Ticket #263 (client time display implementation)
### 3.1 Position and Dimensions
| Property | Value |
|----------|-------|
| Container x | 1720px → 1904px (right-inset 16px, 184px wide) |
| Container y | 16px → 64px (48px tall) |
| Background | `#0e1218` at 40% opacity — barely-there panel behind text |
| Typeface | Michroma Regular 400 |
| Alignment | Right-aligned text, right-inset 8px within container |
This position is intentionally unremarkable. The player should find it without hunting for it.
### 3.2 Smuggler Variant
Single line: the time. Informal, just the clock.
```
09:42
```
| Property | Value |
|----------|-------|
| Text | `{HH}:{MM}` — 24-hour format, always two digits each |
| Font size | 24px |
| Color | `#c8d0e0` (insert chrome) |
| Opacity | 80% passive, 100% when insert active and entity hover |
| Y position | Center within container, ~y: 32px |
No annotation. The smuggler's lattice gives them the time. That's it.
### 3.3 Detective Variant
Two lines: clock on top, shift indicator below. The detective thinks in shifts.
```
09:42
SHIFT A ·
```
| Element | Value |
|---------|-------|
| Time text | `{HH}:{MM}` — Michroma 20px, `#c8d0e0` at 80% passive, y: 26px in container |
| Phase label | `SHIFT {X}` — Michroma 11px, `#c8d0e0` at 60%, y: 50px in container |
| Phase dot | 4px circle, phase-color (see below), same y as phase label |
**Day phase to shift mapping:**
| Game time | Shift label | Dot color |
|-----------|-------------|-----------|
| 05:00–12:00 | `SHIFT A` | `#c8d0e0` (chrome, neutral) |
| 12:00–18:00 | `SHIFT B` | `#c8d0e0` (chrome, neutral) |
| 18:00–22:00 | `SHIFT C` | `#e8c547` (amber — social hours, higher risk of contacts) |
| 22:00–05:00 | `SHIFT OFF` | `#4a9ebb` (teal — night, lower routine activity) |
The dot is a subtle visual cue, not a state machine icon. It gives the detective's lattice a slightly more annotated feel than the smuggler's plain clock.
### 3.4 Insert-Off Behavior
When `insert_active == false`:
- Time display dims to 50% of passive opacity
- Background panel disappears entirely
- Text remains — the character always knows what time it is, even without active lattice engagement. Time is ambient lattice output, not targeting output.
- Phase label (detective) also dims to 50%
The time display is the one persistent insert element. Everything else goes dark on insert deactivation.
---
## 4. Entity Markers
Entity markers appear at each entity's world position — rendered in world space on z-layer 6. They are not minimap icons in a panel; they annotate the world the player is looking at.
### 4.1 Smuggler Variant — Social Network View
The smuggler sees their world as a social network. People are dots. Labels are familiar names.
**Dot specification:**
| Property | Value |
|----------|-------|
| Shape | Filled circle |
| Diameter | 6px |
| Color | D-033 relationship color for this NPC |
| Bloom | 2–3px gaussian at 40% blend |
| Passive state | 60% opacity |
| Active state (hover) | 100% opacity + bloom brightens |
| All non-hovered on hover | 40% opacity (focus effect) |
The dot sits at the entity's sprite anchor point (center-bottom of the 64×64 canvas). It does not float; it attaches to the entity.
**Label specification:**
Named NPCs (Kael, Lera, Sera, etc.) always show a casual label. Generic NPCs show no label unless hovered.
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 70% passive, 100% on hover |
| Position | 8px above the dot, horizontally centered |
| Format | First name only for Known NPCs: `Kael` |
| Format | Location nickname for POIs: `The Last Shift`, `Kael's usual spot` |
| Background | None — label is direct on world view. Keep it legible against zone palettes. |
**Relationship colors applied:**
| NPC state | Dot color | Label format |
|-----------|-----------|--------------|
| Unknown | `#4a9ebb` teal | No label (generic) |
| Known/Friendly | `#6bc9a6` green | First name |
| Person of Interest | `#e8c547` amber | First name + `?` suffix: `Torek?` |
| Hostile | `#d45d5d` red | First name |
The smuggler does not see "Person of Interest." To them, amber means "something's off about this person" — not a case file designation.
### 4.2 Detective Variant — Investigation Overlay
The detective sees their world as a case map. Known individuals are flagged. Locations have institutional labels. The overlay is denser and more annotated.
**Named NPC marker specification:**
| NPC type | Shape | Size | Color |
|----------|-------|------|-------|
| Person of Interest | Diamond (rotated square) | 10px × 10px | `#e8c547` amber |
| Known/Friendly | Filled circle | 6px | `#6bc9a6` green |
| Unknown | Filled circle | 6px | `#4a9ebb` teal |
| Hostile / Suspect | Diamond | 10px × 10px | `#d45d5d` red |
The diamond shape is the detective's POI designation — it communicates "flagged" without text. The smuggler's variant uses only circles because their lattice doesn't have case-file designation logic.
**Label specification:**
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 80% passive, 100% on hover |
| Position | 10px above the marker, right-aligned to marker center |
| NPC label format | `POI: K. Davan` (formal: last name initial + surname) |
| NPC label format | `K. Davan` for Known/non-POI |
| Location label | See §5 |
**Evidence annotation:**
When the detective has logged evidence associated with an entity, a small square appears adjacent to their marker:
| Property | Value |
|----------|-------|
| Shape | Filled square |
| Size | 4px × 4px |
| Position | 4px right of the primary marker |
| Color | `#e8c547` amber |
| Opacity | 60% passive, 100% on hover |
This is the insert's way of flagging "you have something on this person." It doesn't say what.
**Commission grid overlay (Detective only):**
The detective's augmented lattice renders a very faint reference grid across the viewport. This is the "commission-style" aesthetic — the world read as a site map.
| Property | Value |
|----------|-------|
| Grid lines | Horizontal + vertical, every 5 visual tiles (~320px) |
| Line color | `#333340` at 20% opacity |
| Line weight | 1px |
| Behavior | Always visible when insert active, disappears on insert-off |
The grid does not animate, pulse, or react. It is a passive reference overlay. Five lines across the width, five down the height, making a 5×5 grid of reference squares. It is barely visible — felt as structure, not seen as decoration.
---
## 5. Location Labels
Location labels appear at POI anchor points in world space — the entrance tile or center tile of a known location.
### 5.1 Smuggler Variant
Casual labels, written in the smuggler's vocabulary. These are their personal pins.
| Location | Label text |
|----------|-----------|
| The Last Shift (bar) | `The Last Shift` |
| The Terminal (logistics hub) | `The Terminal` |
| Maintenance corridor (smuggling space) | `B-7 drop` (or specific pin label) |
| Kael's regular position | `Kael's usual spot` |
| Property | Value |
|----------|-------|
| Typeface | Michroma 12px |
| Color | `#c8d0e0` at 50% — location labels are backgrounded, not foregrounded |
| Position | Centered on location anchor, 16px above entity-level (below any entity markers at that location) |
| Visibility | Always visible for known locations, fades in on discovery |
### 5.2 Detective Variant
Institutional labels, written in Commission case file format.
| Location | Label text |
|----------|-----------|
| The Last Shift (bar) | `Licensed Establishment · District 7` |
| The Terminal (logistics hub) | `Freight Operations Hub · Level 2` |
| Maintenance corridor | `Restricted Access — Corridor B-7` |
| Kael's regular position | `K. Davan – Last Sighted` |
| Property | Value |
|----------|-------|
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 70% — slightly more prominent than smuggler's labels |
| Position | Same as smuggler, centered on anchor |
| Secondary text | Dot separator `·` between primary name and category/district |
| Visibility | Always visible for known/case-file locations |
---
## 6. Border Arrows (Off-Screen Navigation)
When a known POI or tracked entity is outside the current viewport, a border arrow appears at the screen edge pointing toward them.
**The camera is locked to the player character (D-014).** Border arrows are the only navigation affordance for off-screen known locations. There is no minimap panel.
### 6.1 Arrow Specification
| Property | Value |
|----------|-------|
| Shape | Equilateral triangle, apex pointing toward screen edge |
| Size | 12px × 10px |
| Inset from edge | 8px |
| Color | Matches the POI's relationship color (D-033), or `#c8d0e0` chrome for neutral known locations |
| Opacity | 60% passive, 100% on hover |
| Label on hover | Location name (using appropriate variant's label format), Michroma 10px, appears inside-screen adjacent to arrow |
**Clustering:** When multiple POIs are off-screen in similar directions, arrows stack with 4px gap. Maximum 6 arrows per edge before clustering into a count indicator: `+3` (Michroma 10px, chrome color).
**Decay:** If the character has only `Suspects` confidence about a location (not `KnowsOf`), the border arrow renders at 30% opacity with a dashed border (1px alternating 2px on / 2px off). "Something might be there."
### 6.2 Smuggler vs Detective
- **Smuggler:** Border arrows appear for Known/Friendly NPCs and personal pins. The arrow color is the NPC's relationship color. The label is casual. Unknown NPCs do not generate border arrows.
- **Detective:** Border arrows appear for all case-file entries, POIs, and known locations regardless of relationship state. Evidence-flagged entities show the amber evidence square (4px) adjacent to the arrow.
---
## 7. Inventory Indicators (Smuggler Only)
Per D-065: the smuggler can carry up to 3–4 physical items. Icons appear in the lower-right corner, above the dialogue band.
| Property | Value |
|----------|-------|
| Position | x: 1904px (right inset 16px), y: 816px → 856px |
| Icon size | 40×40px |
| Gap between icons | 8px horizontal, right-to-left stacking |
| Opacity | 80% passive, 100% on hover |
| Empty slots | Not displayed — icons only appear when items are carried |
| Z-layer | 6 (insert layer), but icons are physical objects, not lattice data |
**Item icons (placeholder descriptions for #314 — actual sprites are a separate ticket):**
- Manifest copy: document silhouette
- Corridor access token: hexagonal tag silhouette
- Personal comm log: communication device silhouette
Icons do not disappear when insert is off — they are physical items, not lattice annotations. The character still has pockets whether or not their insert is active.
**Detective inventory:** The detective has 2 inventory slots but they are knowledge-only in v0.1 (D-065). No inventory icons display for the detective in v0.1.
---
## 8. Stance Indicator
Both variants show the current movement stance. This is ambient information — the player set it, but may have forgotten.
| Property | Value |
|----------|-------|
| Position | x: 16px → 180px, y: 826px → 856px (lower-left, above dialogue band) |
| Typeface | Michroma 11px |
| Color | `#c8d0e0` at 50% — intentionally quiet |
| Content | Current stance label: `WALK` / `CAREFUL` / `SPRINT` / `CROUCH` |
| Behavior | Fades to 30% when dialogue is active |
The stance indicator never hides; it just dims. It is the quietest element in the HUD.
---
## 9. Interaction States
### 9.1 Insert Active — Default State
Full overlay as described in §3–§8. Entity markers, location labels, border arrows, time display, stance indicator, inventory icons all render at their passive opacity values.
### 9.2 Insert Inactive (D-056/D-057)
When `insert_active == false`:
| Element | State |
|---------|-------|
| Entity markers | Hidden |
| Location labels | Hidden |
| Border arrows | Hidden |
| Commission grid (detective) | Hidden |
| Evidence annotations | Hidden |
| Bloom | Off — no gaussian pass |
| Time display | Dims to 50% of passive opacity, background panel disappears |
| Stance indicator | Remains — 30% opacity |
| Inventory icons | Remain — physical, not insert data |
| Cursor | Shape still changes on entity proximity (subconscious physical response per D-056) |
The world goes quiet. The character still moves, still perceives physically, still has their items. But the lattice stops annotating.
### 9.3 Entity Hover (Insert Active)
When the player's cursor enters an entity's interaction radius:
1. Hovered entity's marker brightens to 100% opacity + bloom intensifies to 60%
2. All other entity markers dim to 40% opacity
3. Interaction verb appears: Michroma 13px, D-033 color of the target, positioned 16px above the entity marker
4. Entity label brightens to 100%
The interaction verb is the primary affordance for engagement. The verb appears on the insert layer — it disappears when insert is off, and is unaffected by fog (since it's z-layer 6).
Verb examples: `Talk`, `Observe`, `Follow`, `Confront`, `Examine`
### 9.4 Dialogue Active
When a dialogue interaction is in progress:
| Element | Change |
|---------|--------|
| Dialogue box | Appears at bottom 20% (y: 864–1080), z-layer 7 |
| Monologue (if active) | Floats above dialogue box at y: ~800–860, z-layer 7 |
| Insert overlay | Reduces to 60% of active opacity — still visible, lattice still running |
| Entity markers | Remain visible but dimmed 40% |
| Hovered entity marker | Stays bright — the conversation target stays highlighted |
| Inventory icons | Remain visible |
| Time display | Remains |
| Stance indicator | Dims to 30% |
The insert doesn't close during dialogue. The character is still aware of their surroundings.
### 9.5 High Perception Load (D-059)
When multiple perception modes are active simultaneously, the insert shows diegetic interference:
| Property | Value |
|----------|-------|
| Effect | Horizontal scan-line distortion |
| Intensity | 5% — subtle, peripheral |
| Behavior | Periodic (not constant), short bursts, random intervals 2–8 seconds |
| Applies to | All insert elements simultaneously |
| Does NOT apply to | UI z-layer 7 (monologue, dialogue) |
This is a visual warning, not a mechanical penalty. The player reads it as "the lattice is working hard." It should not be alarming; it should be ambient.
---
## 10. Wireframe Mockups
### 10.1 Smuggler Variant — The Last Shift, Evening
The smuggler is in the bar. Kael is nearby (green — Known/Friendly). An unknown dock worker is in the corner (teal). The bar is a known location.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CAREFUL 09:42 │
│ │
│ ↑ The Last Shift (bar: border direction) │
│ │
│ │
│ ● ● │
│ Kael [player] ● │
│ (green) (cream) (teal) │
│ │
│ The Last Shift │
│ (chrome label, centered) │
│ │
│ │
│ [manifest][token]│
├─────────────────────────────────────────────────────────────────────────┤
│ [dialogue box — inactive] │
└─────────────────────────────────────────────────────────────────────────┘
Legend:
● = entity dot marker (6px) Kael = label (Michroma 11px, chrome)
The Last Shift = location label (Michroma 12px, 50% chrome)
[manifest][token] = inventory icons (40×40px, lower-right)
09:42 = time display (Michroma 24px, upper-right)
CAREFUL = stance (Michroma 11px, lower-left, 50%)
```
### 10.2 Detective Variant — Same Location, Same Time
The detective is outside the bar, watching the entrance. Kael is inside, behind a wall — the lattice shows his last known position as a dim marker. A POI is approaching from the corridor (off-screen right).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ WALK [ANALYT] 09:42 │
│ SHIFT C · │
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
│ · ·│
│ · ◆ [player] → ·│
│ · K. Davan (blue-wh) [POI: arrow, amber, off-screen]·│
│ · (amber diamond) ·│
│ · ·│
│ · Licensed Establishment · District 7 ·│
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
│ · ·│
│ · ·│
│ │
│ WALK │
├─────────────────────────────────────────────────────────────────────────┤
│ [dialogue box — inactive] │
└─────────────────────────────────────────────────────────────────────────┘
Legend:
◆ = POI diamond marker (10×10px) K. Davan = formal label (Michroma 11px)
+·····+ = commission grid overlay (Michroma thin, 20% opacity)
→ = border arrow (off-screen POI, amber — K. Davan connection)
Licensed Establishment · District 7 = institutional location label
09:42 / SHIFT C · = time display + shift with amber phase dot
[ANALYT] = perception mode indicator (placeholder position, upper-center-right)
```
### 10.3 Entity Hover State — Detective, POI Hover
```
┌─────────────────────────────────────────────────────────────────────────┐
│ WALK [ANALYT] 09:42 │
│ SHIFT C · │
│ │
│ Talk │
│ Observe │
│ ◆ Confront ←─ interaction verbs (amber, 13px) │
│ K. Davan │
│ [■]◆ (brightened, 100%, bloom 60%) │
│ │
│ all other markers at 40% opacity ─────────────────────────→ │
│ ● │
│ (40%) │
└─────────────────────────────────────────────────────────────────────────┘
[■] = evidence annotation (4×4px amber square, adjacent to diamond)
```
---
## 11. Color Reference Summary
All colors from `visual-grammar-v01.md` §1.5 and §3.2. Reproduced here for implementation reference.
| Use | Hex | Notes |
|-----|-----|-------|
| Insert chrome (default) | `#c8d0e0` | Time display, labels, stance, grid |
| Entity — Unknown | `#4a9ebb` | Cool teal. Default NPC state. |
| Entity — Known/Friendly | `#6bc9a6` | Soft green. Trusted network. |
| Entity — POI | `#e8c547` | Warm amber. Flagged. |
| Entity — Hostile | `#d45d5d` | Muted red. Subjective danger. |
| Player — Detective | `#e0e8ff` | Cool blue-white. |
| Player — Smuggler | `#e8e0d0` | Warm cream. |
| Unexplored wireframe | `#333340` | Not used in HUD chrome directly — world layer use |
| Time panel background | `#0e1218` at 40% | Barely-there. Matches zone ambient palette. |
**Saturation rule:** Entity D-033 colors must remain the most saturated elements on screen at all times. Nothing in the insert chrome may compete with entity color saturation (D-044, §3.6 of visual grammar). The chrome is cool grey-white — it frames entity colors, never rivals them.
---
## 12. Typography Reference
All text is Michroma Regular 400 (Google Fonts). No exceptions (visual-grammar-v01.md §5.4).
| Role | Size | Color | Opacity (passive / active) | Z-layer |
|------|------|-------|---------------------------|---------|
| Time display | 24px (smuggler) / 20px (detective) | `#c8d0e0` | 80% / 100% | 6 |
| Phase label (detective) | 11px | `#c8d0e0` | 60% / 80% | 6 |
| Entity label (hover) | 11px | `#c8d0e0` | 70% / 100% | 6 |
| Location label (smuggler) | 12px | `#c8d0e0` | 50% / 70% | 6 |
| Location label (detective) | 11px | `#c8d0e0` | 70% / 90% | 6 |
| Interaction verb | 13px | D-033 target color | 95% (always active) | 6 |
| Stance indicator | 11px | `#c8d0e0` | 50% / 60% | 6 |
| Border arrow label (hover) | 10px | `#c8d0e0` | 0% / 100% (hover-only) | 6 |
---
## 13. What This Document Does Not Cover
These are deliberately out of scope for #314. Separate tickets own them.
- **Perception mode overlay visual design** — thermal, camera feed, etc. (#315 area, depends on D-017/D-048 full spec)
- **Monologue display system** — typography animation, stacking (#315)
- **Sound indicator visual design** — fog-edge ping pulse (#317)
- **THE FRIEND visual treatment** — special relationship visual affordances (#318)
- **Minimap panel** (if a panel approach is ever adopted — currently border arrows handle navigation, no panel exists in v0.1)
- **World map / inter-location navigation** — a different system entirely
- **Cursor states** — specified in D-056, not repeated here
---
## Appendix A — Integration Notes for Stig (#263)
**For the time display implementation (#263):**
1. The time display container is at **x: 1720–1904, y: 16–64** (right-inset 16px from 1920px width). These are your anchor coordinates.
2. The `ObserverSnapshot` needs to expose current game time (hours, minutes) and current day phase. Consult D-031 for the tick-to-time mapping (10 ticks = 1 game-minute).
3. Time display text must be **right-aligned** within the container. Don't center it — it should feel like a readout in the upper-right corner.
4. The background panel (`#0e1218` at 40%) should be a simple NinePatchRect or ColorRect — minimal, no rounded corners.
5. When `insert_active == false`: set time display `modulate.a = 0.5 * passive_opacity` and hide the background panel. The text stays; the container chrome disappears.
6. For the detective's phase label: drive the shift string from a simple range lookup on the game hour (see §3.3 table above). The dot color is a color constant mapped from phase — not dynamic.
7. The phase label is visible only on `lattice_profile == lattice_augmented`. Single boolean check in the time display node.
**For the entity marker system (future tickets):**
- One marker scene, parametrized by shape (circle vs diamond), size, color, bloom intensity
- The `lattice_profile` drives shape selection: baseline = circles always, augmented = diamonds for POI state, circles for other states
- Evidence annotation (`■`) is a separate child node, shown/hidden based on KnowledgeGraph query for evidence entries on this entity
- Commission grid is a separate CanvasLayer node, only visible when `lattice_profile == lattice_augmented` and `insert_active == true`
---
## Appendix B — Decision Cross-References
| Decision | Relevance |
|----------|-----------|
| D-013 | Diegetic insert/POI navigation. The HUD IS the neural insert. |
| D-014 | Minimap spec: "dots when close, border arrows for known distant POIs" |
| D-019 | Camera tilt — sprites authored for shallow tilt. Markers sit above entity anchor. |
| D-031 | Time system — tick-to-time mapping, day phases. Feeds §3. |
| D-033 | Entity color = relationship to player. All marker colors source from this. |
| D-048 | Neural insert visual design. Bloom spec, character variants. |
| D-049 | Z-layer 6 = insert overlay. Z-layer 7 = UI/monologue. |
| D-051 | "Settling is placement." Object density as meaning. Insert labels reflect this. |
| D-056 | Cursor states persist when insert-off. |
| D-057 | Entity interaction labels suppressed when insert-off. |
| D-059 | Perception load → scan-line interference at 5%. |
| D-065 | Inventory: 40×40px icons, smuggler-only in v0.1. |
| D-066 | Dual-scale grid. Entity markers anchor to visual tile positions. |
+100
View File
@@ -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*
+450
View File
@@ -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).*
+1 -1
View File
@@ -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) |
+136 -72
View File
@@ -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

+99
View File
@@ -0,0 +1,99 @@
# Sprint 18: Touch — Client Tasks
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 17
None. Sprint 17 closed 19/19.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #151 | Minimap rendering | #148/#149 (both done in Sprint 17) |
| #174 | Dialogue UI — client (D-061 spec) | #434 (done) |
| #264 | Knowledge/journal display | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (client is pure renderer, no game logic in GDScript), D-041 (knowledge graph — client displays KG data from snapshot), D-042 (UI microcopy format — YAML via UIStrings autoload)
- `decisions/perception.md` — D-013 (diegetic insert/POI system — minimap is insert-layer UI), D-061 (dialogue box spec — 20% max height, no portraits)
- `decisions/content.md` — D-028 (dialogue architecture — client renders options, server selects), D-062 (invisible locked options), D-064 (walk-away — WASD during dialogue)
- `decisions/scope.md` — D-027 (vertical slice — dual-character POI and knowledge display)
## Notes
### #151 — Minimap rendering
POI data infrastructure (`PointOfInterest` component, discovery events) landed on the server in Sprint 17 (#148, #149). The `ObserverSnapshot` will carry POI data for discovered points. This ticket wires that data into a rendered minimap overlay.
What this ticket must deliver:
- A `MinimapRenderer` scene or node attached to the insert HUD layer (z-layer 6, diegetic insert per D-049)
- Nearby POIs rendered as colored dots at their relative compass position from player origin
- Distant POIs (beyond minimap radius) rendered as directional arrows at the minimap border
- POI dot color and shape vary by category (the server sends `poi_category` in snapshot — use it)
- Player is always centered; minimap does not scroll or rotate (fixed-north, D-015)
- Minimap must be diegetically framed — it reads as a neural insert overlay, not a traditional game HUD
- If no POIs discovered: minimap is empty but the insert frame still renders (the frame is diegetic, always present)
Existing infrastructure to build on:
- `client/scripts/autoloads/game_state.gd` — holds `current_snapshot` which will include POI array from server
- `client/scripts/autoloads/ui_strings.gd` — minimap label strings (add to `client/data/ui-strings.yaml` per D-042)
- `client/scripts/rendering/world_renderer.gd` — reference for how snapshot data drives rendered output
Gotcha: POI positions are in simulation tile coordinates. The minimap renders relative compass direction and distance, not absolute tile positions. Convert server tile positions to player-relative vectors in GDScript.
### #174 — Dialogue UI — client (implement to D-061 spec)
The dialogue box spec (#434, D-061) was delivered in Sprint 7 and is done. This ticket (#174) is the older "Dialogue UI" story whose description has been updated to reference the D-061 spec. Given that the dialogue box, response selection, and walk-away mechanic are already implemented (#434, #435, #437), this ticket now covers the remaining dialogue UI surface not yet wired.
What this ticket must deliver — audit first, then implement gaps:
- Verify the existing dialogue box correctly uses `game_state.current_dialogue` field (set in Sprint 14+)
- **Examine result display**: the examine verb (#242 server) returns a character-filtered text description. The client needs a display path for this — it should appear as a non-interactive overlay (not a dialogue box, no options), floating above the examined entity or in a dedicated "observation" panel. Design to spec: brief, diegetic, auto-dismisses after 4-6 seconds
- **Dialogue UI hardening**: confirm invisible locked options (D-062) — no grayed-out elements, no lock icons anywhere in the dialogue tree
- **Confrontation styling** (#436, D-063): verify italic first-person voice for confrontation options is rendering correctly; confirm the 1-2 second pre-delivery monologue beat fires before the option triggers
Integration point: `client/scripts/autoloads/game_state.gd` holds `current_dialogue`. The rendering layer reads this each frame. Examine result will come through a new `current_examine_result` field (coordinate field name with server team).
### #264 — Knowledge/journal display
Client UI for reviewing accumulated KG facts. The player character's `KnowledgeGraph` is populated server-side and sent down in snapshot as a structured object. This ticket creates the review panel.
What this ticket must deliver:
- A journal/insert panel — toggle key (TBD, coordinate with server team for any keybind — likely `J` or dedicated insert shortcut)
- Displays accumulated facts grouped by entity: "What I know about Kael Davan", then fact entries with confidence level and source
- Fact entries show: fact text, `KnowledgeConfidence` level (Suspects / KnowsOf / KnowsDetails / Direct), source (`DirectObservation` / `ToldBy` / `Heard`), and `last_observed_tick` timestamp converted to game-time string
- `Contradicted` facts rendered with a visual distinction (strikethrough or amber tint) — these are the moments where THE FRIEND arc surfaces in the UI
- `Stale` facts rendered more dimly than `Active` facts
- The display is read-only — no player interaction with entries beyond scrolling
- Diegetic frame: the panel reads as neural insert memory recall. Use `UIStrings` (D-042) for all labels (`client/data/ui-strings.yaml`)
Integration with `game_state.gd`: the snapshot does not currently carry a full KG dump — coordinate with server team. The server team will need to add a `player_knowledge` field to `ObserverSnapshot` (or a separate periodic message). Define the wire format jointly before implementation.
Key gotcha: the journal panel must close when dialogue opens and vice versa — they cannot be open simultaneously. Both compete for insert-layer attention.
## Dependency Chain
```
#151 (minimap) → POI data in snapshot (#148/#149 done) — start immediately
#174 (dialogue UI hardening + examine result display) → examine field from server #242
→ coordinate wire format week 1, implement week 2
#264 (knowledge/journal display) → player_knowledge field in snapshot (coordinate with server)
→ start design week 1, implement after wire format agreed
```
Parallel tracks: #151 can start immediately. #174 and #264 both need a brief coordination with server team on wire format additions — block 30 minutes in week 1 to agree those field names, then implement in parallel.
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
```
+90
View File
@@ -0,0 +1,90 @@
# Sprint 18: Touch — Copy Tasks
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
**Branch:** `copy`
**Agents:** Mellanie (author), Paula (narrative lead), Gestalt (systems)
## Carry-over from Sprint 17
None. Sprint 17 closed 19/19.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #544 | Design: collision-resistant line IDs for auto-generated NPCs | — |
| #158 | Tier 1 drama module schema | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-023 (three-tier content model — Tier 1 is drama modules), D-024 (NPC generation — hundreds of auto-generated NPCs per D-029 population model), D-028 (dialogue line pool format), D-035 (line ID scheme — Amendment Sprint 15: NPC-scoped IDs; D-029 auto-generated NPCs need collision-resistant variant)
- `decisions/scope.md` — D-027 (vertical slice scope — Tier 1 modules activate for the smuggler/detective scenario)
## Notes
### #544 — Design: collision-resistant line IDs for auto-generated NPCs
**Context (Q-028):** The current line ID scheme (D-035 Amendment Sprint 15) uses `{npc-slug}_{d|m}_{###}` — e.g., `kael-davan_d_001`. For hand-authored NPCs with unique slugs this works. But D-029 specifies hundreds of procedurally generated NPCs (the 70% mundane majority), each with a generated slug like `dock-worker`. A district with 40 dock workers all using `dock-worker_d_001` produces immediate collision.
**What this ticket must deliver:**
- A design document (output to `docs/design/` or as a decision record) specifying the collision-resistant scheme for auto-generated NPC line IDs
- Evaluation of at least three options:
1. **Short UUID suffix on NPC slug** — `dock-worker-a3f2_d_001`. Human-readable, unique per NPC, but IDs are not stable across seed changes
2. **StableId prefix** — `npc-00042_d_001`. Machine-readable, stable if `StableId` persists. Less author-friendly
3. **Role slug + instance counter** — `dock-worker-03_d_001`. Human-readable, author can write role-scoped lines used by all instances of that role. Requires a clear definition of "role" as the ID namespace
4. **Slug registry with collision resolution** — same slug gets `dock-worker`, `dock-worker-2`, etc. at generation time, recorded in content registry
- The chosen scheme must satisfy:
- Hand-authored NPCs (Kael, Sera, etc.) retain their current human-readable slugs — no migration
- Auto-generated NPCs can be distinguished from one another in line IDs
- The scheme is implementable in the content registry (`server/src/knowledge/registry.rs`) without breaking existing authored content
- Authors can still write role-scoped lines that apply to all dock workers (shared content), distinct from instance-specific authored lines
- **Gestalt owns this ticket** — it resolves Q-028. Output: a decision record (D-NNN) or documented convention added to `docs/design/`. Once the scheme is chosen, update `docs/design/interaction-verbs-v0.1.md` or the content authoring guide if needed.
Timeline: resolve by end of week 1 so server/ci teams can implement the registry change in Sprint 18 or Sprint 19.
### #158 — Tier 1 drama module schema
Tier 1 content (D-023) is authored drama modules drawn from a pool at game start. The smuggler/detective vertical slice (D-027) IS a Tier 1 module. This ticket defines the structure every Tier 1 module must follow so the storyteller can activate and manage them consistently.
**What this ticket must deliver:**
- A YAML schema definition file at `content/schemas/drama_module.schema.yaml` (or extend existing schema files in `content/schemas/`)
- The schema must cover:
- **Entry conditions**: what world-state must be true for this module to be activatable (NPC present, player relationship threshold, location accessible, etc.)
- **NPC requirements**: which NPC slots the module requires (protagonist, antagonist, witness, etc.) and what axes they must satisfy (e.g., "protagonist must have Major secret")
- **Event sequences**: ordered or unordered events the module can fire, with storyteller trigger conditions for each (proximity, tick threshold, player action)
- **Outcomes**: resolution states the module can reach (exposed, escaped, ambiguous, abandoned)
- **Pool format**: how multiple modules coexist in the pool — each module is a YAML file in `content/modules/tier1/`
- A stub Tier 1 module file for the smuggling ring scenario at `content/modules/tier1/smuggling_ring_v0_1.yaml` — this is the vertical slice module
- Coordinate with server team: the storyteller module stub (`server/src/storyteller/`) will eventually activate modules by reading this schema. Schema design choices constrain implementation — agree on the top-level structure with Tyre before finalizing
**Paula owns the dramatic structure design** (entry conditions, event sequences, outcomes). **Gestalt owns the schema implementation** (YAML format, field names, validation rules). **Mellanie reviews** for authoring ergonomics — can a writer actually fill this template?
Output: schema file + stub module file + brief authoring notes in `docs/design/tier1-module-authoring.md` explaining the fields to future writers.
## Dependency Chain
```
#544 (line ID design) → standalone — Gestalt starts week 1
→ output unblocks server/ci registry implementation (Sprint 19)
#158 (drama module schema) → standalone — Paula + Gestalt start week 1
→ output unblocks storyteller activation (future sprint)
```
Both tickets are design-first — produce documents and schema files, not code. Both run in parallel from day 1.
## Open Questions to Resolve Early
- **Q-028: collision-resistant line IDs** — #544 IS the resolution ticket. Gestalt must produce a concrete decision by end of week 1. The decision should be registered as D-NNN via the standard decision record format in `decisions/content.md`.
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(copy): description" --description "body" --base main --head copy
```
+88
View File
@@ -0,0 +1,88 @@
# Sprint 18: Touch — Joint Tasks
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
## Pre-Sprint
No blocking decisions required before implementation starts. All architectural decisions for Sprint 18 scope are confirmed.
| Decision | Status | Impact |
|----------|--------|--------|
| D-011 (NPCs use same LOS system) | Confirmed | #115 (NPC vision) — no new LOS mechanism needed |
| D-026 (simulation tiers) | Confirmed | #95 (background state machines) — `BackgroundSim` marker exists |
| D-041 (knowledge graph data model) | Confirmed | #242 (examine), #264 (journal display) — KG is the output format |
| D-062 (invisible locked options) | Confirmed | #174 (dialogue UI audit) — no grayed-out options anywhere |
| D-035 Amendment Sprint 15 (NPC-scoped line IDs) | Confirmed | #544 (ID design) extends this for auto-generated NPCs |
| Q-028 (collision-resistant line IDs) | **Open** | #544 resolves this — copy team, week 1 |
## Cross-Team Integration Points
### Server → Client wire format additions (week 1 coordination)
Two new snapshot fields must be agreed before client implementation begins:
| New field | Server ticket | Client ticket | Format notes |
|-----------|--------------|---------------|--------------|
| `examine_result` | #242 | #174 | `{entity_id, text, confidence}` or null — character-filtered observation text |
| `player_knowledge` | #256 (stub) / #242 | #264 | Partial KG dump: `{entities: [{id, name, confidence, source, state, last_tick}], facts: [...]}` |
| `poi_list` | #148/#149 (done) | #151 | Already in snapshot from Sprint 17 — verify field name with server |
Action: server team (Tyre/Dudley) and client team (Stig) align on field names and wire format on **day 1**. No code needed — just agreed field names written to a `.tmp/` file or directly into `server/src/bridge/types.rs` as stub structs.
### Server → Copy dependency
| Server ticket | Copy output | Integration |
|---------------|-------------|-------------|
| #91 (skill system) | #544 (ID scheme) | Auto-generated NPC skills will need line IDs once the NPC pool is large. ID scheme must work for skill-bearing generated NPCs. |
| #115 (NPC vision) | #248 (pressure framework) | Awareness events from #244 drive `exposure_pressure` in #248 — copy team's monologue lines with `mood: [anxious]` are the output surface. |
### Copy → Server dependency
| Copy output | Server ticket | Integration |
|-------------|--------------|-------------|
| #544 (ID scheme decision) | Content registry | Server team cannot implement collision-resistant registry until scheme is decided. Sprint 19 work. |
| #158 (drama module schema) | Storyteller activation | Schema design constrains the storyteller module interface. Server team should review #158 output before storyteller implementation begins. |
## Sprint Completion Proof
Sprint 18 is **DONE** when:
1. **Examine fires and is character-filtered** — Player uses Examine on an NPC at close range. The client displays a short observation text. The smuggler and detective receive different text for the same NPC — same entity, different perspective.
2. **NPC notices the player** — An NPC with the player in its LOS for ≥N consecutive ticks changes behavior: route deviation, posture shift, or tell state update. The player can observe this response.
3. **Social ripple is observable** — Player action toward NPC A (e.g., a trust-positive dialogue) causes a measurable trust delta on NPC B (second-order relationship). Verify via server state inspection or the WRONG button (F12) snapshot.
4. **Tell states wire to snapshot** — Active-tier NPCs with Major secrets show `Nervous` or `Guarded` tell in the `ObserverSnapshot`. The client renders this (monologue trigger or entity tint — verify whichever is wired).
5. **Minimap renders POIs** — Discovered POIs appear as dots/arrows on the insert minimap overlay. Player position is centered. Minimap is present when at least one POI has been discovered.
6. **Journal panel opens** — Player opens journal panel. At least one KG fact entry is visible with confidence level, source, and game-time timestamp.
7. **Background NPCs tick** — A Background-tier NPC's schedule, mood, relationship, and job state visibly change over game-time (verify via WRONG button or server log). Tick rate is once per game-minute (10 ticks per D-031).
8. **Skill system exists** — Generated NPCs have a `SkillSet` component. At least one NPC is spawned with `combat_trained` skill, resulting in a `CombatCapability` marker component attached.
9. **Save data model stubs round-trip** — A `SaveStateV1` struct serializes and deserializes without data loss. Test coverage confirms roundtrip fidelity for: entity positions, KG entries, relationship graph, simulation tick.
10. **Line ID scheme is decided** — Q-028 is resolved. A decision record (D-NNN) exists in `decisions/content.md`. The scheme is documented clearly enough for a writer to apply it immediately.
## Test Plan (D-030 alignment)
Sprint 18 is in the **integration testing** phase (ongoing from Sprint 3 per D-030). The gauntlet infrastructure from Sprint 17 is the primary test harness for server-side verification.
| Ticket | Test approach |
|--------|---------------|
| #242 (examine) | Unit test: `process_examine_interaction` with mock KG → confirm character-filtered output differs between smuggler and detective KG states. Integration test: examine verb from player action → KG entry written via event queue. |
| #244 (NPC awareness) | Unit test: NPC with player in LOS for N ticks → `PlayerAwareness` component threshold crossed → routine deviation fired. |
| #248 (pressure framework) | Unit test: high awareness events → `exposure_pressure` rises. Integration: pressure visible in snapshot HUD data. |
| #249 (social propagation) | Unit test: trust delta +5 to NPC A → NPC B (strong relationship to A) receives delta ~+2. Cycle test: A→B→A propagation terminates cleanly. |
| #337 (tell state wiring) | Integration test: `TellCategory::Nervous` for NPC with Major secret + stress past midpoint → confirmed in snapshot `entities[].tell_state`. |
| #91 (skill system) | Unit test: NPC with `combat_trained` skill in `SkillSet` → `CombatCapability` component present after spawn. Unit test: NPC without `combat_trained` → no `CombatCapability`. |
| #115 (NPC vision) | Unit test: NPC placed adjacent to player (within LOS, no walls) → `NpcVisionState` contains player `StableId`. Wall-blocked: player not visible. |
| #256 (save state) | Unit test: serialize `SaveStateV1` with known state → deserialize → all fields match. Round-trip for `KnowledgeGraph` (already serializable). |
| #95 (background ticks) | Unit test: NPC with `BackgroundSim`, advance 10 ticks → schedule state machine advances. Unit test: mood drift toward neutral after 10 ticks. |
| #151 (minimap) | Manual: POI discovered → minimap dot appears. Distant POI: directional arrow appears at minimap border. |
| #174 (dialogue UI) | Manual: examine result appears as overlay, auto-dismisses. Dialogue options confirmed: no locked/grayed options visible. Confrontation option in italic voice. |
| #264 (journal) | Manual: journal panel opens, KG facts listed with correct metadata. Contradicted facts visually distinct. |
| #544 (ID scheme) | Decision review: scheme handles all four population categories (hand-authored, role-based, generated unique, generated shared). |
| #158 (drama module schema) | Schema review: stub `smuggling_ring_v0_1.yaml` validates against `drama_module.schema.yaml`. |
## Teams
| Team | Branch | Agents | Tickets |
|------|--------|--------|---------|
| server | `server` | Dudley, Tyre, Hoshe | #242, #244, #248, #249, #337, #91, #115, #256, #95 |
| client | `client` | Stig, Tyre, Hoshe | #151, #174, #264 |
| copy | `copy` | Mellanie, Paula, Gestalt | #544, #158 |
+175
View File
@@ -0,0 +1,175 @@
# Sprint 18: Touch — Server Tasks
**Goal:** The player can examine entities and objects to generate character-filtered observations; NPCs detect and react when watched; social actions propagate through the relationship graph; minimap renders POIs on the client.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 17
None. Sprint 17 closed 19/19.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #242 | Examine mechanic | #240 (done) |
| #244 | NPC player-awareness behavior | — |
| #248 | Character goal/pressure framework | — |
| #249 | Player-action social propagation | — |
| #337 | Tell state derivation system | #323 (done) |
| #91 | Skill system & combat flag | — |
| #115 | NPC vision system | — |
| #256 | Save state data model | — |
| #95 | Background tier state machines | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-010 (information boundaries), D-020 (IPC architecture), D-026 (simulation tiers), D-041 (knowledge graph data model)
- `decisions/perception.md` — D-011 (fog of perception — NPCs use same LOS), D-035 (symmetric shadowcasting)
- `decisions/content.md` — D-024 (NPC 10-axis model, skill set axis, combat component), D-028 (dialogue architecture — examine verb is a dialogue layer entry point)
- `decisions/scope.md` — D-027 (vertical slice criteria — character-specific observation)
## Notes
### #242 — Examine mechanic
The interaction dispatcher (`server/src/simulation/interaction.rs`) already computes `VerbKind::ExamineNpc` and `VerbKind::ExamineObject` in the `NearbyInteractionBuffer`. The examine verb appears at close range (≤2 tiles, `CLOSE_RANGE`). `PlayerAction` dispatch and `process_player_input` are the entry points in `server/src/simulation/input.rs`.
What this ticket must deliver:
- A `process_examine_interaction` system that handles `PlayerAction::Examine { entity_id }` (or equivalent)
- Generates a detailed `ObservationEvent` with low uncertainty for the target entity
- Applies character-specific filtering via the observer's `KnowledgeGraph` (same NPC looks different to smuggler vs detective — smuggler reads cargo-handling posture, detective reads procedural tells)
- Result is written to the observer's `KnowledgeGraph` via `KnowledgeEventQueue` as a `DirectObservation` entry with `KnowledgeConfidence::Direct`
- Emits an examine result field in `ObserverSnapshot` so the client can display character-filtered detail text
Integration points: `server/src/simulation/interaction.rs` (verb dispatch), `server/src/knowledge/graph.rs` (`KnowledgeGraph` write), `server/src/perception/observation.rs` (observation event pattern), `server/src/simulation/dialogue.rs` (examine result mirrors dialogue result pattern).
### #244 — NPC player-awareness behavior
NPCs use the same LOS system as the player (D-011). The awareness system detects when an NPC's LOS query includes the player's `TilePosition`, and generates a behavioral response.
What this ticket must deliver:
- A new `PlayerAwareness` component on Active-tier NPCs tracking: whether the player is in this NPC's LOS, for how many consecutive ticks, and accumulated suspicion level
- A `detect_player_awareness` system running after `compute_observer_snapshot` — iterate Active-tier NPCs, run a simplified LOS check or piggyback on existing shadowcast state
- When awareness crosses threshold: routine deviation behavior (NPC changes path or posture), fed into the `DerivedTellState` pipeline (already exists in `server/src/npc/tell_state.rs`)
- Feeds follow-verb suspicion in `server/src/simulation/follow.rs`
Key file: `server/src/simulation/follow.rs` already has proximity + attention logic for follow suspicion — awareness system reuses this infrastructure. New system lives in `server/src/simulation/` or `server/src/npc/`.
### #248 — Character goal/pressure framework
Defines systemic pressures per character that modulate monologue salience and observation priority. Not scripted arcs — emergent from interaction of existing axes (D-024).
What this ticket must deliver:
- A `CharacterPressure` component on the player character entity: `exposure_pressure: i32` (smuggler), `institutional_pressure: i32` (detective), `relationship_pressure: i32` (both)
- Pressure inputs: exposure rises when NPCs notice the player (feeds from #244), relationship pressure from trust changes in `server/src/npc/relationships.rs`, institutional pressure from detective-specific interaction patterns
- Pressure outputs: written into `ObserverSnapshot` HUD widget data; high pressure raises monologue trigger weight for anxiety-tagged lines
- Coordinate with copy team — monologue lines using `mood: [anxious]` or `mood: [frustrated]` tags (D-035) are the output surface
This is a design-and-implement ticket — start by defining the pressure struct, then wire inputs from existing systems. Monologue salience weighting is the primary v0.1 output.
### #249 — Player-action social propagation
Player actions toward one NPC ripple through the relationship graph at three decay orders (D-029 topology principle). The `RelationshipGraph` resource (`server/src/npc/relationships.rs`) and `TrustEventQueue` are the integration points.
What this ticket must deliver:
- A `propagate_social_actions` system triggered when a `TrustEventQueue` event fires from player action
- First-order: immediate full delta to the directly affected NPC
- Second-order: `delta * 0.4` to NPCs with strong relationships to the first-order NPC (trust > 3 in `RelationshipGraph`)
- Third-order: `delta * 0.15` to NPCs one further hop away, delayed by configurable ticks
- Propagation topology varies per seed (D-029 anti-metagaming) — the same action produces different cascades depending on who knows whom
- Write propagated trust changes back to `TrustEventQueue` or directly to `Relationships` components with a `PropagatedTrust` marker
Gotcha: propagation must not loop (A affects B affects A). Visited-entity set per propagation pass prevents cycles.
### #337 — Tell state derivation system
The `derive_tell_state` system already exists and is fully tested in `server/src/npc/tell_state.rs`. This ticket existed in the backlog because the mood state machine (#323) it depends on was not yet done. #323 is now done.
What this ticket must deliver:
- Verify `derive_tell_state` runs correctly in the current schedule (it is already registered in `server/src/npc/mod.rs` after `mood::update_mood`)
- Wire `DerivedTellState` into the observer snapshot output — confirm `ObserverSnapshot.entities[].tell_state` is populated for visible entities
- Integration test: NPC with Major secret + stress past midpoint shows `TellCategory::Nervous` in snapshot
- This ticket is mostly verification + integration wiring, not new code — the system is complete, the sprint task is closing the loop into the snapshot
### #91 — Skill system & combat flag
What this ticket must deliver:
- A `SkillSet` component: `BTreeMap<String, u8>` of named skills with level values (BTreeMap per D-010 determinism requirement)
- When a `SkillSet` contains `"combat_trained"` with value ≥ 1, the ECS system attaches a `CombatCapability` marker component to that NPC at spawn time
- `SkillSet` added to the NPC generation pipeline in `server/src/npc/generate.rs` (already sets other D-024 axes)
- The `CombatCapability` component is a zero-sized marker for now — future sprints add stats
NPC skill sets are generated from content YAML at startup. The content loader in `server/src/content/` reads NPC definitions — add `skills: {}` as a YAML field on NPC templates.
### #115 — NPC vision system
NPCs must use the same LOS shadowcasting system as the player (D-011 — "Applies to ALL entities"). The shadowcast machinery lives in `server/src/perception/shadowcast.rs`.
What this ticket must deliver:
- NPC vision is computed via `compute_los` (or equivalent call) for Active-tier NPCs each tick
- Results stored in an `NpcVisionState` component: set of `StableId` values currently visible to this NPC, plus the player entity if visible
- NPC memory: `NpcMemory` component tracking last-known-position of the player even after leaving LOS ("saw you enter building → knows you're inside" per D-011)
- Inference stub: if player was seen entering a room, NPC `KnowledgeGraph` records `DirectObservation` of player at that room's zone, degrading to `KnowsOf` after configurable ticks
This feeds #244 (awareness) — the `detect_player_awareness` system reads `NpcVisionState` rather than running its own LOS query.
Performance note: only run LOS for NPCs whose `TilePosition` is within `ACTIVE_RADIUS` (already guaranteed by `ActiveSim` marker). Full shadowcast per NPC per tick is feasible at 30-80 active NPCs — Tyre has confirmed the budget.
### #256 — Save state data model
Define the serialization format for full game state. Shares architecture with #96 (state serialization system, still backlog — this ticket is the data model design, not the save/load implementation).
What this ticket must deliver:
- A `SaveStateV1` struct (versioned from day one) covering: entity state, `KnowledgeGraph` per entity (already serializable via `serde` in `server/src/knowledge/graph.rs`), `RelationshipGraph`, game clock position (`SimulationTime`), seed value
- Write format: MessagePack (consistent with IPC protocol per D-020) or RON for human-readable debugging — decide and document
- The struct must roundtrip cleanly: serialize + deserialize produces identical ECS world state
- Stub tests proving the roundtrip; full save/load flow is #257 (future sprint)
The `KnowledgeGraph` is already `Serialize + Deserialize`. The main design work is enumerating which ECS components must be captured and in what order (deterministic serialization per D-010).
### #95 — Background tier state machines
Background-tier NPCs (marked `BackgroundSim` in `server/src/simulation/tier.rs`) currently receive no simulation — tier markers exist but no background tick systems run. This ticket adds the four D-026 state machines for background NPCs.
What this ticket must deliver:
- A `background_tick` system gated by `With<BackgroundSim>` that fires once per game-minute (every 10 ticks per D-031)
- Four mini state machines per background NPC:
1. **Schedule**: advance NPC to next routine activity based on `DayPhase` (reads `DayPhase` from `server/src/simulation/time.rs`, updates `Routine` component)
2. **Mood**: simple mood drift toward neutral; significant events (stress > threshold) can shift from neutral
3. **Relationships**: trust drift toward baseline over time; no events-driven trust changes for background NPCs
4. **Job**: job performance score drift based on contentment (lower contentment → lower performance)
- Background tick does NOT run pathfinding, LOS, or dialogue — those are Active-tier only
- Background NPCs promoted to Active receive their current state machine state (no reset on promotion)
## Dependency Chain
```
#95 (background tier state machines) → standalone, no blockers
#115 (NPC vision system) → #244 (player-awareness behavior)
↓
#248 (character goal/pressure framework) ← feeds from awareness events
#337 (tell state wiring) → standalone, verify + wire into snapshot
#242 (examine mechanic) → standalone (dispatcher already exists)
#249 (social propagation) → standalone (relationships already exist)
#91 (skill system & combat flag) → standalone
#256 (save state data model) → standalone (design + stub)
```
Parallel tracks: #95, #91, #337, #256, #249, and #242 can all start in week 1. #244 starts after #115 is in review.
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
```
+122
View File
@@ -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
```
+128
View File
@@ -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
```
+97
View File
@@ -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 |
+152
View File
@@ -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
```
+190
View File
@@ -0,0 +1,190 @@
# Test Plan: Sprint 18 — Touch (Client)
- **Date**: 2026-02-25
- **Sprint**: 18 (Touch)
- **Spec references**: D-013, D-041, D-042, D-049, D-061, D-062, D-063, D-064, D-078
- **Tickets**: #151 (minimap rendering), #174 (dialogue UI hardening + examine result), #264 (knowledge/journal display)
- **QA Engineer**: Hoshe
---
## Summary
Sprint 18 client scope delivers three UI features:
1. **#151** — Minimap overlay rendering POIs from snapshot
2. **#174** — Dialogue UI hardening (D-062/D-063/D-064) + examine result display overlay
3. **#264** — Knowledge/journal panel displaying accumulated KG facts
Automated tests: `client/tests/test_dialogue_sprint18.gd`, `client/tests/test_journal_sprint18.gd`
Manual tests: this document (section §Manual Test Procedures)
---
## #151: Minimap Rendering
### Spec reference
D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), D-049 (z-layer 6)
### Automated (unit-testable)
- `GameState.poi_list` accessible via snapshot or `current_snapshot.poi_list`
→ Covered: `test_journal_sprint18.gd::test_gamestate_poi_list_field_exists_or_in_entities`
- `CANVAS_INSERT = 10` sanity check
→ Covered: `test_journal_sprint18.gd::test_canvas_insert_constant_is_10`
### Edge cases
- **Empty POI list**: minimap frame still renders (frame is always present per D-013)
- **POI beyond minimap radius**: renders as directional arrow at border, not dot
- **POI at exactly player position**: dot at center
- **All POI categories**: `NavPoint`, `PersonOfInterest` — distinct colors/shapes
### Manual test procedure
1. Start game with a clean save (no discovered POIs)
2. **Verify**: minimap insert frame is visible, empty, no dots/arrows
3. Move player near a NavPoint POI; trigger discovery
4. **Verify**: colored dot appears on minimap at correct compass position
5. **Verify**: dot color/shape matches expected category visual (see `data/ui-strings.yaml`)
6. Move player so a POI is beyond minimap radius
7. **Verify**: directional arrow appears at minimap border pointing toward POI
8. **Verify**: player dot remains centered; minimap does not rotate or scroll
9. Open dialogue box; **verify**: minimap remains visible (not hidden by dialogue)
### Performance check
- 150×150 map, 15 NPCs, 8+ discovered POIs → minimap renders without visible frame drop
---
## #174: Dialogue UI Hardening + Examine Result Display
### Spec reference
D-061 (box spec), D-062 (invisible locked options), D-063 (confrontation), D-064 (walk-away), D-078 (overheard log)
### Automated coverage
Test file: `client/tests/test_dialogue_sprint18.gd`
| Test | D-ref | Status |
|------|-------|--------|
| D-062: rendered options have mouse_filter=STOP | D-062 | Written |
| D-062: no lock icon children on options | D-062 | Written |
| D-062: MAX_OPTIONS = 3 | D-061 | Written |
| D-062: 4 options → only 3 render | D-061/D-062 | Written |
| D-063: CONFRONTATION_BEAT_DURATION in [1.0, 2.0] | D-063 | Written |
| D-063: CONFRONTATION_DIM_ALPHA < 1.0 | D-063 | Written |
| D-063: confrontation_monologue signal fires | D-063 | Written |
| D-063: standard option does NOT fire beat signal | D-063 | Written |
| D-064: _WALK_AWAY_ACTIONS not empty | D-064 | Written |
| D-064: cardinal directions in walk-away list | D-064 | Written |
| D-064: dialogue_dismissed signal exists | D-064 | Written |
| GameState current_dialogue set from snapshot | D-061 | Written |
| GameState current_dialogue null when absent | D-061 | Written |
| GameState current_examine_result field exists | #174 | Written (test-first) |
| GameState current_examine_result set from snapshot | #174 | Written (test-first) |
| GameState current_examine_result null when absent | #174 | Written (test-first) |
| BBCode escape brackets in server text | — | Written |
| _log_dirty flag optimization | — | Written |
| D-061 max height ratio = 0.2 | D-061 | Written |
| D-064 FADE_OUT = 0.3s | D-064 | Written |
| D-078 passive glyph = ┃ | D-078 | Written |
### Items requiring Stig implementation (test-first stubs will fail until done)
- `GameState.current_examine_result` field + `apply_snapshot()` handler
- Examine result overlay scene (`res://ui/examine_overlay.tscn` or similar)
- Auto-dismiss timer: 4–6 seconds (wire `current_examine_result` to overlay)
### Manual test procedure — D-062 (invisible locked options)
1. Enter dialogue with an NPC that has some filtered options (server omits locked ones)
2. **Verify**: dialogue box shows only the options the server sent — no grayed-out entries, no lock icons
3. **Verify**: all visible options respond to click/key press
4. **Verify**: pressing 1, 2, 3 selects the corresponding option (key bindings active)
5. **Red flag**: if you see any visual element that appears "disabled" or "locked" — that is a D-062 violation
### Manual test procedure — D-063 (confrontation beat)
1. Enter dialogue with an NPC that has a confrontation option (italic monologue beat)
2. **Verify**: confrontation option renders identically to standard options (same style — no bold, no icon)
3. Select the confrontation option
4. **Verify**: a first-person internal monologue appears (italic, MonologueDisplay)
5. **Verify**: dialogue box dims for ~1.5 seconds during beat
6. **Verify**: after beat, option is sent and conversation ends normally
7. **Verify**: audio dip applies during confrontation beat
### Manual test procedure — Examine result display (#174 new feature)
1. Stand adjacent to an NPC; press Examine key (TBD — coordinate with server team)
2. **Verify**: a brief text overlay appears (non-interactive, no response options)
3. **Verify**: overlay is diegetically styled (insert layer, not a dialogue box)
4. **Verify**: overlay auto-dismisses after 4–6 seconds without player input
5. **Verify**: different characters (detective vs smuggler) receive different text for the same NPC
6. **Verify**: overlay does not appear over a dialogue box (mutual exclusion)
---
## #264: Knowledge/Journal Display
### Spec reference
D-041 (knowledge graph data model), D-042 (UIStrings), D-013 (insert layer)
### Automated coverage
Test file: `client/tests/test_journal_sprint18.gd`
| Test | D-ref | Status |
|------|-------|--------|
| GameState.player_knowledge field exists | D-041 | Written (test-first) |
| GameState.player_knowledge set from snapshot | D-041 | Written (test-first) |
| GameState.player_knowledge null when absent | D-041 | Written (test-first) |
| Facts array survives snapshot roundtrip | D-041 | Written (test-first) |
| KnowledgeConfidence levels documented | D-041 | Written |
| Fact state values documented | D-041 | Written |
| Journal scene exists at path | #264 | Written (test-first) |
| UIStrings has journal section | D-042 | Written (test-first) |
| CANVAS_INSERT = 10 | D-013 | Written |
| POI list accessible for minimap | #151 | Written |
### Items requiring Stig implementation (test-first stubs will fail until done)
- `GameState.player_knowledge` field + `apply_snapshot()` handler
- Journal panel scene (`res://ui/journal_panel.tscn`)
- Journal panel `refresh()` or `_refresh()` method
- UIStrings keys: `journal.title`, `journal.confidence.*`, `journal.no_facts`
- Toggle key (likely `J`) wired to panel visibility
- Mutual exclusion: journal closes when dialogue opens and vice versa
### Manual test procedure
1. Accumulate KG facts by examining NPCs and participating in dialogue
2. Press the journal toggle key (likely `J`)
3. **Verify**: journal panel opens as an insert-layer overlay (diegetic styling)
4. **Verify**: entities are listed with header ("What I know about Kael Davan")
5. **Verify**: each fact shows: fact text, confidence level, source, game-time timestamp
6. **Verify**: `Direct` confidence facts are most prominent (visually)
7. **Verify**: `Stale` facts appear dimmer than `Active` facts
8. **Verify**: `Contradicted` facts are visually distinct (strikethrough or amber tint)
9. Open dialogue box; **verify**: journal panel closes automatically
10. Close dialogue; re-open journal; **verify**: state preserved
11. Press `J` again; **verify**: journal panel closes
### Edge cases
- **Empty journal**: no facts accumulated → journal shows "No data" message (UIStrings key)
- **Many facts**: 20+ facts → scroll works, panel stays within insert layer bounds
- **Contradicted + Stale**: a fact can be both — verify combined visual treatment
- **Game time 00:00**: timestamp displays correctly (midnight edge case)
---
## Test Coverage Summary
| Ticket | Automated tests | Manual procedure documented | Ready to run |
|--------|----------------|----------------------------|--------------|
| #151 minimap | 2 (structural) | Yes | Blocked on Stig (#151 in_progress) |
| #174 dialogue hardening | 25 | Yes | All pass today (code exists) |
| #174 examine result | 5 (test-first) | Yes | Blocked (GameState field missing) |
| #264 journal display | 8 (test-first) | Yes | Blocked (scene + field missing) |
### Test files to run
```bash
# gdUnit4 headless (see docs/DEVOPS.md for full command)
# test_dialogue_sprint18.gd — expect: 25 pass (dialogue), 5 fail (examine, test-first)
# test_journal_sprint18.gd — expect: 3 pass (constants), 8 fail (test-first)
```
### Sprint 18 completion criteria (client)
Per `sprint-18/joint.md`:
- [ ] Minimap renders POIs — discovered POI shows dot; player centered; at least one distant POI shows arrow
- [ ] Journal panel opens — at least one KG fact with confidence, source, game-time visible
- [ ] Examine result displays — overlay fires on Examine, auto-dismisses, differs by character
- [ ] No locked/grayed dialogue options anywhere in the UI (D-062)
@@ -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).*

Some files were not shown because too many files have changed in this diff Show More