Compare commits

..
95 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 615c3a9c17 chore(meta): release v0.1.20
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:52:38 +01:00
jpmschweitzerandClaude Opus 4.6 c8a0f1b327 chore(db): backup database after sprint 20 merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:51:51 +01:00
jpmschweitzer 3c634a937d Merge remote-tracking branch 'origin/server' 2026-02-25 23:50:56 +01:00
jpmschweitzerandClaude Opus 4.6 4ef1157b79 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:35 +01:00
jpmschweitzerandClaude Opus 4.6 d808d95659 refactor(simulation): address PR #72 review suggestions
- Hoshe #3: replace O(n²) Vec scan in fallback NPC assignment with
  BTreeSet; prevent same NPC assigned to two roles in one triangle
- Hoshe #4: add From impls for RoleId, TriangleId, StableId, and
  TriangleCrisisEventWire — eliminate fragile .0 access on newtypes
- Hoshe #5: consolidate near-identical unit tests with integration
  counterparts — keep only unique tests in #[cfg(test)] module
- Tyre #3: replace O(N*M) scan in apply_resolve_triangle with
  BTreeMap<TriangleId, Entity> index for O(1) per-command lookup
- Tyre #4: document &mut World on generate_intra_template_triangles
- Observer snapshot uses TriangleCrisisEventWire::from instead of
  manual field mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:23 +01:00
jpmschweitzerandClaude Opus 4.6 3a555a2eeb fix(simulation): validate dangling with_role in TriangleDef constraints
TriangleDef.validate() now rejects relationship constraints where
with_role references a role not in the triangle's three roles.
Catches authoring errors at load time instead of silently producing
broken constraint data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:23 +01:00
jpmschweitzerandClaude Opus 4.6 1ea3d9c724 fix(simulation): persist TriangleState in SaveStateV1
Triangle phase and tension were silently lost on save/load. Now
serialized as triangle_states vec in SaveStateV1, sorted by
triangle_id for determinism (D-010). Dedicated triangle entities
are respawned on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:18 +01:00
jpmschweitzerandClaude Opus 4.6 9bc5925220 fix(simulation): wire triangle crisis event queue into observer snapshot
The TriangleCrisisEventQueue was populated by tick_triangle_escalation
but never drained into ObserverSnapshot — clients always saw an empty
vec despite protocol v16 advertising the field. Now drains the queue
each tick and converts to TriangleCrisisEventWire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:11 +01:00
jpmschweitzerandClaude Opus 4.6 d34fc970a7 feat(simulation): add triangle escalation integration tests
16 tests covering escalation timing, crisis event emission,
resolve command, D-087 seed-dependent timing, D-089 no-cascade,
and edge cases (dormant skip, saturation, idempotent resolve).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:27 +01:00
jpmschweitzerandClaude Opus 4.6 918dffc643 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:05 +01:00
jpmschweitzerandClaude Opus 4.6 89158f2f2a chore(simulation): regenerate msgpack fixtures for protocol v16
Updates all client-side msgpack test fixtures and server test
harnesses to include the new triangle_crisis_events field added
in protocol v16.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:32 +01:00
jpmschweitzerandClaude Opus 4.6 c436ae905f feat(simulation): wire triangle escalation and crisis events into protocol
Registers tick_triangle_escalation and apply_resolve_triangle systems
in SimulationPlugin. Adds TriangleCrisisEventWire to ObserverSnapshot
(protocol v16) for future client rendering of triangle crises (#250,
D-087). Observer emits empty vec by default; escalation system will
populate when triangles reach Active phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:21 +01:00
jpmschweitzerandClaude Opus 4.6 9eaca0b5a4 feat(simulation): persist template ownership and references in save state
Adds TemplateOwnership to NpcSaveState and TemplateReferenceMap to
SaveStateV1 so cross-template links survive save/load and tier
eviction (D-025, D-026). Both fields use serde(default) for backward
compatibility with existing saves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:13 +01:00
jpmschweitzerandClaude Opus 4.6 0e8ac56fc1 feat(simulation): add social site template schema types
Implements #163 (RoleSchema), #164 (SpaceSpec), #165 (TemplateOwnership
+ TemplateReferenceMap), #106 (TriangleDef), #107 (intra-template
triangle generation), and #250 (triangle escalation system) as the
foundational Tier 2 template system per D-025.

New content/template module with YAML-deserializable schema types,
ECS components for ownership/triangle state, escalation system
running on game-minute boundaries, and TriangleCrisisEvent emission.
Sample YAML templates at server/data/templates/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:06 +01:00
jpmschweitzerandClaude Opus 4.6 98100da0d0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:23 +01:00
jpmschweitzerandClaude Opus 4.6 de136fc1a5 refactor(client): unify duplicate YAML parsers into YamlParser (#560)
Extract shared YamlParser utility (client/scripts/util/yaml_parser.gd)
with parse() for nested typed dicts and parse_flat() for dotted-key
string format. UIStrings._parse_yaml() and ChecklistEvaluator's inline
parser both delegate to YamlParser, removing ~140 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:05 +01:00
jpmschweitzerandClaude Opus 4.6 964bb9c459 refactor(client): extract SnapshotEventRouter from main.gd (#559)
New SnapshotEventRouter class (46 lines) provides callable-based
snapshot dispatch via register(), register_always(), and dispatch().
main.gd _process() now calls _router.dispatch(snapshot) instead of
15+ inline if-has blocks. Handlers registered in _ready().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:56 +01:00
jpmschweitzerandClaude Opus 4.6 6f9c97ca03 refactor(client): decouple dialogue_box from GameState and AudioManager (#558)
Replace 3 direct GameState.dialogue_active mutations and all
AudioManager.apply_dip/clear_dip calls with signals:
dialogue_state_changed, audio_dip_requested, audio_dip_cleared.
dialogue_box.gd now has zero references to GameState or AudioManager.
main.gd wires coordinator handlers in _ready() (D-020).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:47 +01:00
jpmschweitzerandClaude Opus 4.6 c8de1a0629 refactor(client): make stationary_ticks and zone_id server-authoritative (#557)
apply_snapshot() now reads stationary_ticks and zone_id directly from
the server snapshot when present (D-020 compliance). Client-side
accumulation and tile lookup retained as deprecated fallbacks until
the server populates these fields. Protocol.gd extended with decode
paths and TODO markers for the server team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:38 +01:00
jpmschweitzerandClaude Opus 4.6 95a9b349a0 docs(docs): add district topology diagram
D2 source + PNG render showing all zone connections, access tiers,
z-levels, maintenance corridor routing, and dual entry vectors.
Vertical layout with color-coded access tiers per D-093.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:34 +01:00
jpmschweitzerandClaude Opus 4.6 e4a3e0ce60 docs(docs): add gate cluster spatial layout
Full tile-level layout doc matching Terminal and Bar format. 7 zones
across z=1 and z=2, sightline matrix, access tier map, NPC traffic
density, Triangle 5 narrative notes. Per D-093 gate cluster spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:22 +01:00
jpmschweitzerandClaude Opus 4.6 33e0c51218 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:09:11 +01:00
jpmschweitzerandClaude Opus 4.6 1b5825cf39 docs(docs): add generator architecture workshop brief
Workshop brief for ticket #562 covering the top-down district
generator pipeline (Cities Skylines model). Builds on D-094
chunk/block/district hierarchy. Targets Q-036 and Q-037 resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:40 +01:00
jpmschweitzerandClaude Opus 4.6 5c029cf6ea docs(docs): update sova station profile for horizon station lore
Corrects horizon gate description per D-095: gates are at the Krenn
Ring (800 AU orbital installation), not on Station Sova. Admin Hub
houses transit processing facility, not gate apertures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:35 +01:00
jpmschweitzerandClaude Opus 4.6 98d28b5a54 docs(discussions): archive workshop #153 station district layout
Three-round workshop producing D-093 (Sova Transit District spatial
layout), D-094 (chunk/block/district hierarchy), D-095 (horizon
stations and transport lore). Resolves Q-040, Q-041, Q-043, Q-044;
partially resolves Q-042. Establishes spatial hierarchy as
architectural precedent for Q-036 generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:29 +01:00
jpmschweitzerandClaude Opus 4.6 81b64b3cf7 chore(db): backup database after Sprint 19 close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:58:41 +01:00
jpmschweitzer 7d501c2c58 Merge remote-tracking branch 'origin/planning'
# Conflicts:
#	CHANGELOG.md
2026-02-25 16:58:30 +01:00
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 d676a8f41e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:29 +01:00
jpmschweitzerandClaude Opus 4.6 6bf7d342f3 chore(skills): add planning team ticket type to sprint-plan
Supports design discussion tickets that run on the planning branch
with a purpose-assembled agent panel. Includes Qatux (documenter)
and SI (project manager) for bookkeeping alongside domain agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:16 +01:00
jpmschweitzerandClaude Opus 4.6 2175349c56 docs(sprints): add sprint 20 "Shape" briefings
Server (6 tickets): template role/spatial/ownership schemas, triangle
definition, generation, and escalation. Client (4 tickets): code quality
refactors from review pass. Planning (1 ticket): #153 station district
layout design discussion with purpose-assembled agent panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:11 +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 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 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
jpmschweitzer 4c812058f2 chore(meta): resolve CHANGELOG conflict — merge main into visual 2026-02-25 02:04:45 +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
155 changed files with 21480 additions and 822 deletions
+8
View File
@@ -46,6 +46,14 @@
"Bash(tea *)",
"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(ls *)",
"Bash(find *)",
+5 -3
View File
@@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow
### 0. Branch guard — MUST be on `main`
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
```bash
git branch --show-current
```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run from the `main` worktree. Switch to `main` first."
Do NOT proceed with the review from a team branch.
"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
+30
View File
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
When writing briefings, name the assigned agents in the **Agents** line of each
file so the team knows who to spawn.
### Planning Team Tickets
Some tickets need **design discussion** before implementation can begin — tagged
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
questions. These run on the `planning` branch as structured discussions with
the user and a purpose-assembled agent panel.
**When to create a planning ticket:**
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
- Ticket blocks 2+ downstream tickets across different teams
- Open Q-NNN items that block sprint candidates
- Architectural decisions that need multi-domain input before implementation
**Planning briefing format** (differs from implementation briefings):
- **Agents line**: List agents by domain relevance, not fixed team roster.
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
records decisions, updates domain files) and SI (project manager — creates
follow-up tickets, updates sprint assignments).
- **Discussion rounds**: Structure the conversation into 2-3 rounds
(inventory → proposals → convergence)
- **Context section**: List all existing design docs, decisions, and related
tickets that participants must read before the discussion
- **Output specification**: What the discussion must produce — typically a
D-record in `decisions/`, possibly a design doc in `docs/design/`
- **Decision questions**: Specific questions the discussion must answer,
not open-ended exploration
## Workflow
### 1. Run sprint prepare
+51 -12
View File
@@ -288,24 +288,63 @@ Task(
prompt: "You are on the {team} team for Sprint {N}.
Branch: `{team}`
RULES:
- GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead.
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
db/connectors/ticket show 528
db/connectors/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
RULES (NON-NEGOTIABLE):
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead. No exceptions.
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
db/connectors/ticket show 528
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
2. Read the decision files referenced in the briefing.
3. Check TaskList for available work.
4. Claim an unblocked task (TaskUpdate with owner: your name),
mark it in_progress, and implement it.
5. When done, mark the task completed and check TaskList for
the next available task.
5. Before marking done, verify:
- 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.",
description: "Sprint {N} {team}: {name}",
+1
View File
@@ -21,6 +21,7 @@ run_check() {
# --- Checks ---
run_check "tooling/check-fact-ids" "fact_id validation"
run_check "tooling/check-decision-ids" "decision ID duplication"
if [ "$ERRORS" -gt 0 ]; then
echo ""
+1
View File
@@ -43,3 +43,4 @@ Thumbs.db
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
.claude/plans/
.claude/projects/
.claude/agent-memory/
+88
View File
@@ -6,12 +6,94 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.20] — 2026-02-25
### Added
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
### Fixed
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
- Validate dangling with_role references in TriangleDef constraint validation
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
- Consolidate near-identical unit tests with integration counterparts
### Changed
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
## [v0.1.19] — 2026-02-25
### Added
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
- 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)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
@@ -26,6 +108,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- D-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)
@@ -40,6 +123,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- 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)
### 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
- 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
+2
View File
@@ -56,12 +56,14 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
| 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 index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- 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.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- 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 \
fixtures-client golden-diff golden-update \
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 ---
@@ -23,11 +24,15 @@ help:
@echo " make stop Stop any running server instance"
@echo " make client Run the Godot client (test mode)"
@echo " make server Run the Rust simulation server"
@echo " make test Run all tests"
@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 test Run all tests"
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
@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 clean Remove build artifacts and caches"
@echo ""
@@ -128,7 +133,7 @@ stop:
test: test-server test-client
test-server:
cd server && cargo nextest run
tests/run-rust
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
@@ -169,8 +174,19 @@ golden-update:
@echo "Review with: git diff --cached -- server/tests/golden/"
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
tests/run-godot
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 ---
+16
View File
@@ -104,6 +104,9 @@ notifications:
# System
save_complete: "Progress saved."
load_complete: "Session restored."
save_failed: "Save failed."
load_failed: "Load failed."
connection_lost: "Signal interrupted."
connection_restored: "Signal restored."
@@ -125,6 +128,17 @@ knowledge_panel:
confidence_medium: "Likely"
confidence_low: "Unconfirmed"
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)
@@ -159,6 +173,8 @@ dialogue:
menu:
pause_title: "Paused"
resume: "Resume"
new_game: "New Game"
continue: "Continue"
settings: "Settings"
save_game: "Save"
load_game: "Load"
+17 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
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/icon="res://icon.svg"
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
SessionManager="*res://scripts/autoloads/session_manager.gd"
[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)
]
}
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={
"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)
]
}
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]
+12 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=24 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/rendering/world_renderer.gd" id="2_world"]
@@ -23,6 +23,8 @@
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[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"]
script = ExtResource("1_main")
@@ -134,6 +136,15 @@ layer = 10
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
[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) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
@@ -141,8 +152,6 @@ layer = 20
[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")]
; 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)
+91 -18
View File
@@ -1,9 +1,19 @@
extends Node
signal game_id_changed(new_id: String)
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
# Tiles use format: [{x, y, z, type}].
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 player_position: Vector2 = Vector2.ZERO
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).
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)
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_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.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = []
@@ -79,14 +109,17 @@ var medium_sound_events: Array = []
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
# Fallback: client-side accumulation (deprecated, remove when server populates field).
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
# D-020: Read directly from snapshot "zone_id" field.
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
# Empty string when zone_id field absent.
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
@@ -110,12 +143,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
# Prefer server-sent value; fall back to client-side accumulation until server populates.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
# D-020: direct field assignment from server-authoritative snapshot.
stationary_ticks = snapshot.stationary_ticks
else:
stationary_ticks = 0
# DEPRECATED fallback — client-side accumulation. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
# driving state on the client). Server tracks this in ListeningFocus component.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
@@ -240,16 +280,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
medium_sound_events = []
close_sound_events = []
# 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).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# 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-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
# Prefer server-sent top-level value; fall back to client-side tile lookup until
# server populates top-level "zone_id" in ObserverSnapshot.
if snapshot.has("zone_id") and snapshot.zone_id is String:
# D-020: direct field assignment from server-authoritative snapshot.
current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
# identity on the client via tile iteration). Server sends zone_id per
# VisibleTile but not as a top-level snapshot field.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
+19 -2
View File
@@ -19,8 +19,11 @@ enum Action {
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
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)
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] = []
@@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("open_journal"):
action = Action.OPEN_JOURNAL
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
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:
input_queue.append({
var entry := {
"action": action,
"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()
+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
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
var _test_tick: int = 0
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 harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
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 _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
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal handshake_complete(protocol_version: int)
signal handshake_failed(reason: String)
func _ready() -> void:
if test_mode:
harness = TestHarness.new()
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:
_test_tick = 0
_test_player_pos = Vector2i(10, 10)
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
if harness: harness.reset()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
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
func _set_state(new_state: ConnectionState) -> void:
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
# 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)])
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# 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:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
_set_state(ConnectionState.CONNECTED)
_handshake_start_usec = Time.get_ticks_usec()
_set_state(ConnectionState.HANDSHAKING)
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
StreamPeerTCP.STATUS_ERROR:
@@ -134,6 +179,62 @@ func _process(delta: float) -> void:
_bridge = null # Reset and retry
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:
return
@@ -170,9 +271,13 @@ func _process(delta: float) -> void:
push_warning("SimBridge: connection lost")
_set_state(ConnectionState.DISCONNECTED)
# -- Input / snapshot ----------------------------------------------------------
# Send input to simulation server.
# 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.
func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
@@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error:
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
_test_facing = facing
harness.process_facing(facing)
else:
_test_input_queue.append(wire_name)
harness.process_input(wire_name)
return OK
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
# action_enum_to_wire already emits push_warning for invalid actions
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 entry: Dictionary = { "tick": tick, "action_name": action_name }
# Data variants (e.g. UsePerceptionMode) carry payload
var action_data: Variant = player_input.get("action_data")
if action_data != null:
entry["action_data"] = action_data
@@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error:
return OK
# 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:
if state != ConnectionState.CONNECTED:
return null
if test_mode:
var snapshot = _test_snapshot()
var snapshot = harness.snapshot()
snapshot_received.emit(snapshot)
return snapshot
@@ -252,6 +352,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_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
# Drain the outbound buffer. Returns raw input entries for batch encoding.
@@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]:
_outbound_buffer.clear()
return inputs
# -- Wire protocol mapping -----------------------------------------------------
# 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.
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)
InputMapper.Action.TELEPORT_HUB:
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)
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"
+2 -40
View File
@@ -49,44 +49,6 @@ func reload() -> void:
## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var stack: Array = [] # [[indent, key], ...]
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
# Trailing comment without a value — treat as section header
if val.begins_with("#"):
val = ""
# Pop sections at same or deeper indent
while stack.size() > 0 and stack.back()[0] >= indent:
stack.pop_back()
if val.is_empty():
# Section header — push onto stack
stack.push_back([indent, key])
else:
# Leaf value — extract from quotes or strip inline comment
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
val = val.substr(1, end_quote - 1)
else:
val = val.substr(1)
else:
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
var dotted_key := ""
for entry in stack:
dotted_key += entry[1] + "."
dotted_key += key
strings[dotted_key] = val
return strings
return YamlParser.parse_flat(text)
+3 -105
View File
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
# -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
return parse_checklist_yaml(text)
## Delegates to YamlParser.parse() (#560).
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
return YamlParser.parse(text)
+144 -74
View File
@@ -15,6 +15,9 @@ extends Node2D
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var 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 bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@@ -30,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
@@ -62,11 +66,51 @@ func _ready() -> void:
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_propagate_insert_state)
_router.register_always(_update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_play_recognition_chimes)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state)
if time_display:
_router.register_always(time_display.update_from_state)
if journal_panel:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_play_close_sound_events)
_router.register_always(_update_zone)
_router.register_always(_update_listening_focus)
_router.register_always(_consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consume_monologue)
_router.register("current_dialogue", _consume_dialogue)
_router.register("conversation_events", _consume_conversation_events)
_router.register("conversation_ended", _consume_conversation_ended)
_router.register("dialogue_response", _consume_dialogue_response)
_router.register("save_result", _consume_save_result)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
@@ -86,80 +130,9 @@ func _process(delta: float) -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
var insert_state := GameState.insert_active
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
cursor_renderer.set_insert_active(insert_state)
if interaction_list and interaction_list.has_method("set_insert_active"):
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
inventory_grid.update_from_state()
# D-053: Update stance indicator
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# D-059/D-060: Update fog entity visualization (#431)
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# #559: Dispatch snapshot to registered handlers (router pattern).
# Always-run handlers update child nodes; keyed handlers fire for present fields.
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
@@ -184,6 +157,10 @@ func _process(delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
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
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
@@ -228,6 +205,32 @@ func _process(delta: float) -> void:
_pending_record_inputs.clear()
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
func _propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func _update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
@@ -318,6 +321,9 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
return
_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
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
@@ -362,6 +368,27 @@ func _consume_dialogue_response() -> void:
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
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
@@ -413,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
})
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
func _on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
func _on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
func _on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
@@ -456,6 +499,33 @@ func _teleport_transition() -> void:
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.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
+100 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 13
const PROTOCOL_VERSION: int = 15
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -206,6 +206,99 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"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"),
}
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
# When server populates this field, client-side accumulation fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var stationary_ticks: Variant = null
var raw_st: Variant = raw.get("stationary_ticks")
if raw_st != null:
stationary_ticks = int(raw_st)
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
# When server populates this, client-side tile iteration fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var zone_id: Variant = null
var raw_zid: Variant = raw.get("zone_id")
if raw_zid is String:
zone_id = raw_zid
# 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 {
"tick": tick,
"entities": entities,
@@ -223,6 +316,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"pending_recognitions": pending_recognitions,
"conversation_events": conversation_events,
"conversation_ended": conversation_ended,
"poi_list": poi_list,
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
"stationary_ticks": stationary_ticks,
"zone_id": zone_id,
}
+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"
+45
View File
@@ -0,0 +1,45 @@
class_name SnapshotEventRouter
## Routes snapshot fields to registered handlers (D-020, #559).
##
## Decouples main.gd from knowing which child node handles which snapshot field.
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
##
## Two handler types:
## - Keyed: called only when the snapshot contains a specific field.
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
##
## All handlers are zero-argument callables — they read from GameState directly.
## This preserves GameState as the single source of truth post-apply_snapshot().
## Keyed handlers: field_name → Array[Callable]
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
var _keyed: Dictionary = {} # String → Array[Callable]
## Always handlers: called every dispatch regardless of snapshot content.
var _always: Array[Callable] = []
## Register a handler for a specific snapshot field key.
## Handler is called (with no arguments) when snapshot.has(field) is true.
## Multiple handlers per field are supported — they run in registration order.
func register(field: String, handler: Callable) -> void:
if not _keyed.has(field):
_keyed[field] = []
_keyed[field].append(handler)
## Register a handler that runs every dispatch tick (not keyed to a field).
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
func register_always(handler: Callable) -> void:
_always.append(handler)
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
func dispatch(snapshot: Dictionary) -> void:
for handler in _always:
handler.call()
for field in _keyed:
if snapshot.has(field):
for handler in _keyed[field]:
handler.call()
+362 -17
View File
@@ -1,5 +1,15 @@
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 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 LINE_HEIGHT := 16
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 _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:
_dev_mode = OS.is_debug_build()
visible = false
_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:
if not _dev_mode:
return
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
if not _dev_mode or not visible:
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()
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:
if not visible:
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
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
@@ -71,7 +200,10 @@ func _draw() -> void:
left_lines.append(["mode", mode_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_value_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 box_w: float = max(header_w, content_w) + PADDING.x * 2
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)
# Header
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)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
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)
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
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)
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
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
+163
View File
@@ -0,0 +1,163 @@
class_name YamlParser
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
##
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
## (as UIStrings._parse_yaml() requires).
##
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
##
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
## Parse YAML text into a hierarchical Dictionary.
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
## Values are type-inferred: bool, int, float, or String.
static func parse(text: String) -> Dictionary:
var root: Dictionary = {}
# Stack: [{indent: int, key: String}] — path of open section headers
var stack: Array = []
# Array state
var current_array: Variant = null # Array being built, or null
var current_item: Variant = null # Dict being built for current array item, or null
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
for raw_line in text.split("\n"):
var stripped := raw_line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
var content: String = stripped.strip_edges()
# --- Array item (- key: value) ---
if content.begins_with("- "):
# First item: convert parent section's {} placeholder to []
if current_array == null and stack.size() > 0:
var parent := _node_at(root, stack, true)
var arr_key: String = stack.back()["key"]
var new_arr: Array = []
parent[arr_key] = new_arr
current_array = new_arr
array_parent_indent = stack.back()["indent"]
# Flush previous item and start a new one
if current_item != null:
current_array.append(current_item)
current_item = {}
var rest: String = content.substr(2).strip_edges()
var colon: int = rest.find(":")
if colon >= 0:
var k: String = rest.substr(0, colon).strip_edges()
var v: String = rest.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- Continuation line within current array item ---
if current_array != null and indent > array_parent_indent:
var colon: int = content.find(":")
if colon >= 0 and current_item != null:
var k: String = content.substr(0, colon).strip_edges()
var v: String = content.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- End of array (indent has returned to array level or above) ---
if current_array != null:
if current_item != null:
current_array.append(current_item)
current_item = null
current_array = null
array_parent_indent = -1
if stack.size() > 0:
stack.pop_back() # pop the array-owning key
# --- Regular key: value or section header ---
var colon: int = content.find(":")
if colon < 0:
continue
var key: String = content.substr(0, colon).strip_edges()
var val_str: String = content.substr(colon + 1).strip_edges()
# Pop sections at the same or deeper indent (we're back at a shallower level)
while stack.size() > 0 and stack.back()["indent"] >= indent:
stack.pop_back()
var node: Dictionary = _node_at(root, stack, false)
if val_str.is_empty() or val_str.begins_with("#"):
# Section header — create nested dict (may become Array if - items follow)
node[key] = {}
stack.push_back({"indent": indent, "key": key})
else:
node[key] = _parse_value(val_str)
# Flush the last array item if the file ended inside an array
if current_array != null and current_item != null:
current_array.append(current_item)
return root
## Convenience: parse text and flatten to dotted-key format in one call.
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
static func parse_flat(text: String) -> Dictionary:
return flatten(parse(text))
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
## {"a": {"b": "v"}} → {"a.b": "v"}
## Arrays are skipped — dotted-key format does not represent them.
## All values are converted to String (UIStrings stores display text, not typed data).
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
var result: Dictionary = {}
for k in d:
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
var v = d[k]
if v is Dictionary:
result.merge(flatten(v, full_key))
elif not v is Array:
result[full_key] = str(v)
return result
## Parse a single YAML value string into a typed GDScript value.
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comment outside quotes
if not val.begins_with("\""):
var comment_pos: int = val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string — extract content between quotes
if val.begins_with("\""):
var end_quote: int = val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true": return true
if val == "false": return false
# Float (must have decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
## Navigate root following the stack key path.
## parent=true: navigate one level less (returns the parent node, not the leaf).
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
var node: Dictionary = root
var depth: int = stack.size() - (1 if parent else 0)
for i in range(depth):
var k: String = stack[i]["key"]
if node.has(k) and node[k] is Dictionary:
node = node[k]
else:
break
return node
+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()
+672
View File
@@ -0,0 +1,672 @@
## 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()
# ---------------------------------------------------------------------------
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
# directly mutating GameState or calling AudioManager.
# ---------------------------------------------------------------------------
func test_dialogue_state_changed_emits_true_on_show() -> void:
## D-020: show_dialogue() must emit dialogue_state_changed(true).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has(true)).override_failure_message(
"dialogue_state_changed(true) must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_dialogue_state_changed_emits_false_on_hide() -> void:
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(received.has(false)).override_failure_message(
"dialogue_state_changed(false) must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.audio_dip_requested.connect(func(profile): received.append(profile))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has("dialogue")).override_failure_message(
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_cleared_emits_on_hide() -> void:
## D-020: hide_dialogue() must emit audio_dip_cleared.
var box := _make_dialogue_box()
if box == null: return
var cleared := [false]
box.audio_dip_cleared.connect(func(): cleared[0] = true)
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(cleared[0]).override_failure_message(
"audio_dip_cleared must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_no_direct_game_state_mutation() -> void:
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
## After show_dialogue, GameState.dialogue_active should remain unchanged
## (only the coordinator updates it via signal handler).
var box := _make_dialogue_box()
if box == null: return
GameState.dialogue_active = false
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(GameState.dialogue_active).override_failure_message(
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
).is_false()
box.queue_free()
GameState.dialogue_active = false
+143
View File
@@ -0,0 +1,143 @@
## Sprint 20 #558: dialogue_box.gd decoupling tests.
##
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestDialogueSprint20
extends GdUnitTestSuite
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint20: 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 before_test() -> void:
GameState.dialogue_active = false
func after_test() -> void:
GameState.dialogue_active = false
# -- dialogue_state_changed signal -------------------------------------------
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
).is_equal(true)
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
## Without a connected handler, GameState.dialogue_active must stay false.
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
GameState.dialogue_active = false
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
).is_false()
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
## hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.hide_dialogue()
assert_that(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
).is_equal(false)
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received_profile: Variant = null
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received_profile).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
).is_equal("dialogue")
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var dip_before := AudioManager.get_active_dip()
box.show_dialogue("NPC", "Hello.", [])
var dip_after := AudioManager.get_active_dip()
assert_str(dip_after).override_failure_message(
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
).is_equal(dip_before)
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var cleared := false
box.audio_dip_cleared.connect(func(): cleared = true)
box.hide_dialogue()
assert_bool(cleared).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared (#558)"
).is_true()
func test_audio_dip_cleared_count_on_conversation_end() -> void:
## Verify audio_dip_cleared fires when conversation ends.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var cleared_count := 0
box.audio_dip_cleared.connect(func(): cleared_count += 1)
box.show_dialogue("NPC", "Speak.", [])
box.hide_dialogue()
assert_int(cleared_count).override_failure_message(
"audio_dip_cleared must fire at least once when conversation ends (#558)"
).is_greater_equal(1)
# -- coordinator wiring verification -----------------------------------------
func test_signal_handler_wires_game_state() -> void:
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
## Verifies the coordinator pattern works end-to-end (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
).is_true()
box.hide_dialogue()
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
).is_false()
@@ -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()
+215
View File
@@ -0,0 +1,215 @@
## 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._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
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 ticks: server-authoritative (D-020/D-071) -----------------
func test_stationary_ticks_from_server_snapshot() -> void:
## D-020: When server sends stationary_ticks, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
assert_int(GameState.stationary_ticks).is_equal(42)
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
## D-020: Server value takes priority — client must not accumulate on top of it.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"stationary_ticks": 5,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
GameState.stationary_ticks = 0
GameState.apply_snapshot({"tick": 1, "entities": []})
# No crash; field retains a valid integer value.
assert_int(GameState.stationary_ticks).is_greater_equal(0)
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
## Deprecated fallback: client accumulates when server omits the field.
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 sentinel
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_fallback_resets_on_movement() -> void:
## Deprecated fallback: client resets on movement when server omits the field.
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)
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
func test_zone_id_from_server_snapshot() -> void:
## D-020: When server sends top-level zone_id, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
func test_zone_id_server_value_overrides_tile_derivation() -> void:
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"}],
"zone_id": "zone_from_server",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
func test_zone_id_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
GameState.current_zone_id = ""
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.current_zone_id).is_equal("")
# -- 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()
+164
View File
@@ -0,0 +1,164 @@
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
##
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
## for stationary_ticks and zone_id directly from the snapshot when present,
## and degrades gracefully when the server has not yet added these fields.
##
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
## existing behaviour. This file covers the new code paths added in Sprint 20.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameStateSprint20
extends GdUnitTestSuite
func before_each() -> void:
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.visibility_sectors = {}
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
func test_stationary_ticks_reads_server_value_when_present() -> void:
# When snapshot includes stationary_ticks, apply_snapshot() must use the
# server value directly without client-side accumulation (D-020).
var snapshot := {
"tick": 5,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 42,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
).is_equal(42)
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
# Server-sent value must be assigned directly — NOT added to existing value.
# Two calls with stationary_ticks=10 must yield 10, not 20.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 10,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server value must be assigned directly, not accumulated (D-020)"
).is_equal(10)
func test_stationary_ticks_server_can_reset_to_zero() -> void:
# Server sends 0 when player moves — client must accept this reset.
GameState.stationary_ticks = 50
var snapshot := {
"tick": 2,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 0,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server reset to 0 must override client-held value"
).is_equal(0)
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
func test_stationary_ticks_fallback_when_field_absent() -> void:
# When snapshot has no stationary_ticks, client-side accumulation must still
# run (backward compat until server ships field). No crash.
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) # position changes from ZERO → resets to 0
GameState.apply_snapshot(snapshot) # position unchanged → increments
assert_int(GameState.stationary_ticks).override_failure_message(
"Client-side fallback must increment stationary_ticks when field absent"
).is_greater(0)
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
var snapshot := {"tick": 1, "entities": []}
# No assertion needed beyond confirming no exception is raised.
GameState.apply_snapshot(snapshot)
assert_bool(true).is_true()
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
func test_current_zone_id_reads_server_value_when_present() -> void:
# When snapshot includes top-level zone_id, apply_snapshot() must use it
# directly without tile lookup (D-020).
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_server_direct",
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
).is_equal("zone_server_direct")
func test_current_zone_id_server_value_overrides_tile_data() -> void:
# When snapshot has both zone_id and visible_tiles with a different zone,
# the top-level zone_id field takes priority.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_from_server",
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Top-level zone_id must override tile-derived zone when both present"
).is_equal("zone_from_server")
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
# smoke test confirming the fallback path still works after Sprint 20 refactor.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Tile lookup fallback must populate zone_id when top-level field absent"
).is_equal("zone_tile_fallback")
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
# Snapshots lacking both zone_id and visible_tiles must not crash.
var snapshot := {"tick": 1, "entities": []}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).is_equal("")
+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")
@@ -0,0 +1,89 @@
## SnapshotEventRouter unit tests (#559).
## Verifies callable-based dispatch: keyed handlers receive correct field values,
## always handlers run on every dispatch, absent fields don't trigger handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestSnapshotEventRouter
extends GdUnitTestSuite
# -- Keyed handlers -----------------------------------------------------------
func test_keyed_handler_called_when_field_present() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"current_monologue": {"text": "Test."}})
assert_that(received.size()).is_equal(1)
assert_that(received[0]).is_equal("monologue")
func test_keyed_handler_not_called_when_field_absent() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"tick": 1})
assert_that(received.size()).is_equal(0)
func test_multiple_keyed_handlers_same_field() -> void:
## Multiple handlers on the same key run in registration order.
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("save_result", func(): order.append("first"))
router.register("save_result", func(): order.append("second"))
router.dispatch({"save_result": {"success": true}})
assert_that(order).is_equal(["first", "second"])
func test_keyed_handlers_multiple_fields() -> void:
## Each keyed handler fires only for its registered field.
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("mono"))
router.register("current_dialogue", func(): received.append("dlg"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(received).is_equal(["mono"])
received.clear()
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
assert_bool(received.has("mono")).is_true()
assert_bool(received.has("dlg")).is_true()
# -- Always handlers ----------------------------------------------------------
func test_always_handler_called_on_every_dispatch() -> void:
var router := SnapshotEventRouter.new()
var count: Array[int] = [0]
router.register_always(func(): count[0] += 1)
router.dispatch({"tick": 1})
router.dispatch({"tick": 2})
router.dispatch({})
assert_int(count[0]).is_equal(3)
func test_always_handler_runs_before_keyed() -> void:
## Always handlers run before keyed handlers (dispatch order guarantee).
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("current_monologue", func(): order.append("keyed"))
router.register_always(func(): order.append("always"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(order[0]).is_equal("always")
assert_that(order[1]).is_equal("keyed")
# -- Empty dispatch ------------------------------------------------------------
func test_empty_snapshot_no_crash() -> void:
var router := SnapshotEventRouter.new()
router.register("foo", func(): pass)
router.register_always(func(): pass)
# Must not crash
router.dispatch({})
func test_no_handlers_no_crash() -> void:
var router := SnapshotEventRouter.new()
# Must not crash
router.dispatch({"tick": 1, "entities": []})
+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:
# 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")
_instance = scene.instantiate()
auto_free(_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:
+261
View File
@@ -0,0 +1,261 @@
## YamlParser unit tests (#560).
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
## quoted strings, empty input, edge cases.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestYamlParser
extends GdUnitTestSuite
# -- parse(): basic key-value pairs -------------------------------------------
func test_parse_simple_key_value() -> void:
var result := YamlParser.parse("key: value")
assert_that(result["key"]).is_equal("value")
func test_parse_quoted_string() -> void:
var result := YamlParser.parse('key: "hello world"')
assert_that(result["key"]).is_equal("hello world")
func test_parse_empty_quoted_string() -> void:
var result := YamlParser.parse('key: ""')
assert_that(result["key"]).is_equal("")
func test_parse_integer_value() -> void:
var result := YamlParser.parse("count: 42")
assert_that(result["count"]).is_equal(42)
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
func test_parse_negative_integer() -> void:
var result := YamlParser.parse("offset: -3")
assert_that(result["offset"]).is_equal(-3)
func test_parse_float_value() -> void:
var result := YamlParser.parse("radius: 2.5")
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
func test_parse_boolean_true() -> void:
var result := YamlParser.parse("enabled: true")
assert_that(result["enabled"]).is_equal(true)
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
func test_parse_boolean_false() -> void:
var result := YamlParser.parse("enabled: false")
assert_that(result["enabled"]).is_equal(false)
func test_parse_empty_input() -> void:
var result := YamlParser.parse("")
assert_that(result.size()).is_equal(0)
func test_parse_only_comments() -> void:
var result := YamlParser.parse("# comment\n# another")
assert_that(result.size()).is_equal(0)
func test_parse_inline_comment_stripped() -> void:
var result := YamlParser.parse("room_id: test # this is a comment")
assert_that(result["room_id"]).is_equal("test")
func test_parse_quoted_value_with_hash() -> void:
## Quoted strings preserve literal # characters.
var result := YamlParser.parse('color: "#e0e8ff"')
assert_that(result["color"]).is_equal("#e0e8ff")
func test_parse_value_with_colon() -> void:
var result := YamlParser.parse('time: "12:30"')
assert_that(result["time"]).is_equal("12:30")
# -- parse(): nested maps -----------------------------------------------------
func test_parse_nested_map() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result.has("section")).is_true()
assert_that(result["section"] is Dictionary).is_true()
assert_that(result["section"]["key"]).is_equal("value")
func test_parse_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse(yaml)
assert_that(result["a"]["b"]["c"]).is_equal("deep")
func test_parse_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["mode"]).is_equal("Mode")
assert_that(result["interaction"]["talk"]).is_equal("Talk")
func test_parse_multiple_keys_per_section() -> void:
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["a"]).is_equal(1)
assert_that(result["hud"]["b"]).is_equal(2)
assert_that(result["hud"]["c"]).is_equal(3)
func test_parse_sibling_subsections() -> void:
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["states"]["a"]["x"]).is_equal(1)
assert_that(result["states"]["b"]["x"]).is_equal(2)
func test_parse_section_with_trailing_comment() -> void:
## "section: # comment" should be treated as a section header.
var yaml := "section: # comment\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result["section"]["key"]).is_equal("value")
# -- parse(): arrays of dicts -------------------------------------------------
func test_parse_single_array_item() -> void:
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
var result := YamlParser.parse(yaml)
assert_that(result.has("conditions")).is_true()
assert_that(result["conditions"] is Array).is_true()
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
assert_that(result["conditions"][0]["type"]).is_equal("near")
assert_that(result["conditions"][0]["x"]).is_equal(10)
func test_parse_multiple_array_items() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
assert_that(result["conditions"][0]["id"]).is_equal("a")
assert_that(result["conditions"][1]["id"]).is_equal("b")
func test_parse_array_items_with_blank_lines() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
func test_parse_top_level_plus_array() -> void:
## Checklist format: top-level key-value pairs followed by a conditions array.
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("warehouse")
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("c1")
func test_parse_array_typed_values() -> void:
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
var result := YamlParser.parse(yaml)
var item: Dictionary = result["items"][0]
assert_that(item["x"]).is_equal(42)
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
assert_that(item["active"]).is_equal(true)
# -- parse_flat(): dotted keys -------------------------------------------------
func test_flat_simple() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("section.key")).is_true()
assert_that(result["section.key"]).is_equal("value")
func test_flat_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse_flat(yaml)
assert_that(result["a.b.c"]).is_equal("deep")
func test_flat_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Mode")
assert_that(result["interaction.talk"]).is_equal("Talk")
func test_flat_values_are_strings() -> void:
## parse_flat returns all values as strings, unlike parse() which returns typed.
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.count"]).is_equal("42")
assert_that(result["section.rate"]).is_equal("0.9")
assert_that(result["section.active"]).is_equal("true")
func test_flat_quoted_value() -> void:
var yaml := 'section:\n key: "hello world"'
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.key"]).is_equal("hello world")
func test_flat_inline_comment() -> void:
var yaml := "hud:\n mode: Standard # default"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Standard")
func test_flat_empty_quoted_value() -> void:
var yaml := 'hud:\n prefix: "" # No prefix'
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("hud.prefix")).is_true()
assert_that(result["hud.prefix"]).is_equal("")
func test_flat_skips_arrays() -> void:
## Arrays have no dotted-key representation — they are skipped in flat output.
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("room_id")).is_true()
# No dotted keys for array contents
assert_that(result.has("conditions")).is_false()
assert_that(result.has("conditions.0")).is_false()
func test_flat_empty_input() -> void:
var result := YamlParser.parse_flat("")
assert_that(result.size()).is_equal(0)
# -- Dialogue theme format (regression) ----------------------------------------
func test_dialogue_theme_format() -> void:
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
var flat := YamlParser.parse_flat(yaml)
assert_that(flat["player_color"]).is_equal("#e0e8ff")
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
assert_that(flat["passive_opacity"]).is_equal("0.9")
# -- Checklist format (regression) ---------------------------------------------
func test_checklist_format() -> void:
## checklist.yaml: top-level kv + conditions array with typed values.
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("inventory_warehouse")
var cond: Dictionary = result["conditions"][0]
assert_that(cond["id"]).is_equal("test-1")
assert_that(cond["condition_type"]).is_equal("player_near")
assert_that(cond["x"]).is_equal(10)
assert_that(cond["y"]).is_equal(20)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
+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]
+70 -36
View File
@@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
signal unpause_requested # D-061: auto-unpause
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
signal dialogue_state_changed(active: bool)
signal audio_dip_requested(profile: String)
signal audio_dip_cleared
@onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
dialogue_state_changed.emit(true) # D-064: coordinator blocks movement
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-069: Dialogue dip — coordinator routes to AudioManager
audio_dip_requested.emit("dialogue")
# D-061: auto-pause — signal to main.gd for input recording (#507)
pause_requested.emit()
@@ -311,14 +316,14 @@ func _end_player_conversation() -> void:
entry.timestamp_msec = now
_log_dirty = true
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
audio_dip_cleared.emit()
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: unblock movement immediately — log entries stay visible but don't block input.
GameState.dialogue_active = false
# D-064: unblock movement immediately — coordinator handles GameState update.
dialogue_state_changed.emit(false)
# If no entries remain, hide the panel with fade.
if _log_entries.is_empty():
@@ -331,11 +336,11 @@ func hide_dialogue() -> void:
_end_player_conversation()
return # _end_player_conversation may call hide_dialogue if log is empty
GameState.dialogue_active = false
dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
return _in_player_conversation
func has_active_entries() -> bool:
@@ -354,26 +359,45 @@ func _show_options(options: Array) -> void:
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
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
var numbered_text := "%d. %s" % [i + 1, raw_text]
label.text = numbered_text
# D-063: Confrontation options render italic — first-person voice, weighted differently.
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
var ctrl: Control
if is_confrontation:
var rtl := RichTextLabel.new()
rtl.bbcode_enabled = true
rtl.fit_content = true
rtl.scroll_active = false
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
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:
_on_option_pressed(idx)
)
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
options_container.add_child(label)
_option_controls.append(label)
options_container.add_child(ctrl)
_option_controls.append(ctrl)
_option_response_ids.append(opt.get("response_id", ""))
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
@@ -409,12 +433,12 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
AudioManager.apply_dip("confrontation")
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
option_selected.emit(response_id, text)
_end_player_conversation()
)
@@ -424,7 +448,7 @@ func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
_beat_tween = null
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
# -- Log rendering --
@@ -519,7 +543,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
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.
@@ -575,19 +599,22 @@ func _expire_entries() -> void:
var removed := false
var has_fading := false
# Remove expired non-pinned entries from the front (oldest first)
while _log_entries.size() > 0:
var entry: Dictionary = _log_entries[0]
# Remove expired non-pinned entries (oldest first, skipping pinned)
var i := 0
while i < _log_entries.size():
var entry: Dictionary = _log_entries[i]
if entry.pinned:
break # Pinned entries never expire
i += 1
continue # Pinned entries never expire — keep scanning
var age: int = now - entry.timestamp_msec
if age < total_lifetime_msec:
# Check if this entry is in the fading phase
if age > int(_entry_lifetime * 1000.0):
has_fading = true
break
_log_entries.remove_at(0)
_log_entries.remove_at(i)
removed = true
# Don't increment i — element at i is now the next entry
# Check remaining entries for fading state
if not has_fading:
@@ -623,11 +650,18 @@ func _clear_options() -> void:
# Hover callbacks
static func _make_hover_on(label: Control) -> Callable:
static func _make_hover_on(ctrl: Control) -> Callable:
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():
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)
+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
# Minimap — small viewport showing local area
# Placeholder implementation for now
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
##
## 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:
print("Minimap: Initialized")
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
# Update minimap view (stub)
func update_minimap(player_pos: Vector2, entities: Array) -> void:
# TODO: Render minimap view
# This will show a top-down view of the local area
pass
func _process(_delta: float) -> void:
if _insert_active:
queue_redraw()
## 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"]
[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
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -200.0
offset_bottom = 200.0
offset_left = -176.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 176.0
grow_horizontal = 0
mouse_filter = 2
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_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
@@ -62,7 +64,10 @@ func _process(delta: float) -> void:
var now := float(Time.get_ticks_msec())
if now >= _next_fade_in_msec:
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.
@@ -71,6 +76,26 @@ func _process(delta: float) -> void:
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# 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:
if text.is_empty():
return
@@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
# 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:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
_vbox.add_child(line_node)
+13
View File
@@ -121,6 +121,14 @@ func _build_ui() -> void:
close_btn.pressed.connect(close)
_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:
if _container:
@@ -153,6 +161,11 @@ func _draw() -> void:
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:
if db <= -40.0:
return "mute"
+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()
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
HELP_TEXT = """\
Commonwealth Decisions Sync
Commonwealth Decisions Sync & ID Management
Usage:
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
decisions_sync.py --help Show this help message
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
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
decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions
and decision_refs tables.
Idempotent: safe to run repeatedly. References are rebuilt on every sync.
ID claiming workflow:
1. Agent calls 'claim D architecture "Per-game save dirs"'
2. Gets back D-085 (or whatever is next)
3. Agent writes D-085 in the appropriate domain file
4. Pre-commit hook runs check-dupes to catch collisions
Config: {config}
Schema: {schema}
@@ -404,6 +515,18 @@ def main():
if cmd == "sync":
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:
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 |
|------|--------|-----------|
| [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 |
| [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 |
| [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 |
| [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 |
| [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, 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, 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, D-087, D-089, D-091 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-028 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-039 |
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
## Querying Decisions
+36 -1
View File
@@ -210,4 +210,39 @@ 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)
---
### D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes
- **Date:** 2026-02-25
- **Decision:** The spatial hierarchy for map generation and streaming is defined as follows. **Chunk** = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. **Block** = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. **District** = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
- **Rationale:** Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
- **Raised by:** Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
- **Dissent:** Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-012 (tile spec — amended), D-014 (v0.1 map spec — district bounding box superseded), D-066 (dual-scale grid), D-093 (Sova Transit District layout using this hierarchy), Q-036 (district generator)
---
*19 decisions. Last updated: 2026-02-25 (D-094 added — Station District Layout Workshop, Sprint 20)*
+44 -1
View File
@@ -204,6 +204,49 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **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)
---
*17 decisions. Last updated: 2026-02-25 (D-084 added — Q-028 resolution)*
### D-093: Sova Transit District — Spatial Layout and District Topology
- **Date:** 2026-02-25
- **Decision:** The Sova Transit District spatial layout is confirmed. Four social sites (D-025): Terminal (Sova Logistics Hub, 44×28 visual), Bar/Last Shift (28×22 + 6m east ext.), Gate Cluster (40×32 visual, 7 zones — see zone spec below), Sector 3 residential (Drin/Naia anchor, ~15×12 visual, adjacent maintenance spine). Transit platform (The Loop stop, ~12×8 visual, bar-side) is classified as an encounter node, not a social site. District bounding box: 256×256 visual tiles (4×4 blocks per D-094). Three investigation paths confirmed: Path A (pattern recognition via camera logs/manifest), Path B (physical traversal via acoustic gap at restricted storage door seal → maintenance hatch), Path C (institutional, via Commission inspector relationship). Zone palette (surface hex / fog tint): gate cluster #b8bec4 / #0a1222; terminal #7a8490 / #0d1520; bar #6b4018 / #200c04; maintenance #4e5054 / #101214. Gate cluster zone spec (Araminta): aperture chamber 8×4 (restricted); freight staging 24×8 (private); passenger arrival 12×8 (semi-public); freight customs 20×10 (semi-private, 3–5 lanes at 2vt/lane); pedestrian customs 12×10 (semi-public, 3 lanes at 1vt); gate concourse 40×8 (public); observation gallery z=2 32×10 (Commission-only). Corridor widths: maintenance 2vt; internal building 3–4vt; secondary public 4vt; transition 6vt; gate concourse 8vt; service alcoves 1–2vt. Z-level scheme: z=0 maintenance corridor (Era 1, no Meridian), z=1 all main structures, z=2 gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall (player on z=2 sees z=1 below; upward LOS blocked except at staircase). Gate cluster social triangle: operations manager + senior freight handler + Commission inspector. Invisible infrastructure principle (G-08): every ring location reads as mundane; criminal function visible only to those who know. G-11: detective enters via gate cluster (Commission arrival); workers enter via transit platform (bar-side).
- **Rationale:** Emerged from three-round workshop synthesis. Layout satisfies D-025 (social sites), D-036 (Sova Transit District), D-054 (tile movement), D-059 (fog zone palette), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice criteria). Chunk/district hierarchy establishes architectural precedent for Q-036 generator.
- **Raised by:** Full team — Gestalt (gameplay constraints), Miri (worldbuilding/lore), Araminta (visual/spatial), Tyre (technical), Paula (narrative), Ozzie (player experience). Compiled by Qatux.
- **Dissent:** Araminta preferred 32×32 visual chunk size (overruled by lead and team majority). No other dissent.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-025 (social sites), D-036 (Sova setting), D-054 (tile movement), D-059 (fog system), D-066 (dual-scale grid), D-094 (district hierarchy), D-095 (transport lore), Q-036 (generator), Q-040–Q-044 (transport lore questions)
---
### D-095: Horizon Stations and Gate Infrastructure — Transport Lore
- **Date:** 2026-02-25
- **Decision:** Span gates are human-built structures with a single aperture enabling near-instantaneous transit. Operating schedule uses dual-use windows: freight (bulk of hours) and passenger (scheduled slots). Physical layout: aperture chamber → freight staging / passenger arrival → customs lanes → gate concourse. Horizon stations are alien-built installations (no identified builder species), self-maintaining, located at Oort-cloud distance, with 4–8 apertures per station. Per-system canonical name: "The Ring." Travel is sequential-hop only (A→B→C through intermediate systems; no direct long-range transit). Per-system access tiers vary (4 tiers — some systems allow single-hop to orbital customs; no direct planetary span gate). Station Sova's horizon gates are located at The Ring (Oort-cloud orbital); the Administrative Hub contains booking offices only (not the gates themselves — correction to prior station profile text). "The Loop" is Sova's internal tram network: 6 districts, 4-minute run from Residential Core to Transit District. Workers arrive at the transit platform (bar-side) and disperse to Terminal or bar.
- **Rationale:** Resolves transport lore questions Q-040, Q-041, Q-043, Q-044 raised during Workshop #153. Miri's Round 3 contribution. Horizon station as alien-built infrastructure adds worldbuilding depth without requiring a named builder species. Sequential-hop travel creates natural story hooks (layover locations, transit records, smuggling route complexity).
- **Raised by:** Miri (worldbuilding), confirmed by team.
- **Dissent:** None.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-093 (gate cluster spatial layout), Q-040 (gate dual-use topology — resolved), Q-041 (horizon station model — resolved), Q-042 (intra-system transport — partially resolved), Q-043 (station internal transit — resolved), Q-044 (gate-train integration — resolved)
---
*21 decisions. Last updated: 2026-02-25 (D-093, D-095 added — Station District Layout Workshop, Sprint 20)*
+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)
- **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)*
+112 -1
View File
@@ -168,6 +168,117 @@ Tracked questions awaiting discussion or resolution.
- **Assigned to:** Gestalt, Tyre
- **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
### Q-040: Gate dual-use topology — freight and commuter on shared span gate infrastructure
- **Status:** Resolved → D-093 (gate cluster zone spec), D-095 (span gate dual-use windows)
- **Question:** System span gates serve both freight and commuter traffic (one gate per system). How does this work physically? Is it one gate aperture with scheduling (freight window vs. passenger window), or parallel lanes (separate apertures for freight and passenger flows)? What does the gate facility look like from the inside — a single large bay or divided infrastructure?
- **Layout implication:** Affects the gate cluster spatial design in the Transit District — the gate cluster must accommodate both freight staging and passenger throughflow, possibly at different times of day.
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Surfaced by lead correction to Miri's S-02 (commuter transit ≠ second external gate).
- **Cross-reference:** D-036 (Sova setting), Q-036 (district skeleton as generator output)
### Q-041: Interstellar travel mechanics — horizon stations and gate architecture
- **Status:** Resolved → D-095 (horizon stations: alien-built, 4–8 apertures, Oort-cloud, "The Ring"; sequential hop travel)
- **Question:** A system needs MORE than one horizon gate for multi-hop connectivity (one gate allows only 1:1 connections). Lead proposal (Round 3): **Horizon stations** — orbital installations at Oort-cloud distance, partially or wholly understood ancient alien technology, self-maintaining (Mass Effect relay/Citadel analog). Each horizon station holds a FIXED number of active and inactive horizon gates. Some systems may only have one hop to an orbital customs station with no direct planet-side span gate access. Remaining questions: How many gates per horizon station? What determines which gates are active vs. inactive? Is the travel instantaneous or traversal-based? What is "The Ring" (the orbital horizon station) like as a physical space?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Lead correction in Round 3: single-gate-per-system model insufficient for multi-hop travel; horizon station model proposed.
- **Cross-reference:** D-036 (Sova setting), Q-039 (gate topology generation), Q-040 (gate dual-use topology)
### Q-042: Intra-system transport networks — passenger vs. freight, vehicles and modes
- **Status:** Partially resolved → D-095 (span gates at star/planetary level; horizon stations at Oort distance). Intra-system hab-to-hab transit remains open.
- **Question:** How do people and goods move within a star system (between orbital stations, planetary surfaces, and other in-system facilities)? Are there two separate networks (passenger transport and freight transport) or one shared network? What are the vehicle types and transit modes? How does intra-system transit interact with the span gate at the system's hub station?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
- **Cross-reference:** D-036 (Sova setting), Q-043 (station internal transit)
### Q-043: Station internal transit — intra-station transport system between districts
- **Status:** Resolved → D-095 (The Loop: 6-district tram, 4-minute Residential Core → Transit District; transit platform is bar-side encounter node)
- **Question:** What is the intra-station transport system on Station Sova? How do workers commute between districts (e.g., Residential Core → Transit District)? Is it a train, tram, shuttle, or pressurised corridor? What is the travel time and frequency? Where does the transit stop sit within the Transit District — gate-cluster-adjacent (workers arrive near freight operations) or bar-side-adjacent (workers arrive near their social space)?
- **Layout implication for #153:** The Transit District must include an internal transit stop. Its position within the district affects NPC traffic patterns and the district entry topology. This is the active T-03b question for Round 2/3 of the Station District Layout Workshop.
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Arose from lead correction: commuter transit = internal station transit, not a second external gate.
- **Cross-reference:** D-036 (Sova setting), Q-042 (intra-system transport networks), S-02 revision
### Q-044: Gate-train integration — do transport vehicles use gates directly or transfer on each side
- **Status:** Resolved → D-093/D-095 (gates are pedestrian/cargo-only; passengers transfer via gate concourse → transition corridor → transit platform; no direct gate-to-tram connection)
- **Question:** If trains or shuttles are the intra-system or intra-station transit mode, do they use the span gate directly (a train enters the gate and exits at the destination, carriages and all)? Or are the gates pedestrian/cargo-only, requiring passengers and freight to transfer to separate transport on each side? What does this imply for gate terminal design — does it need platforms, or just processing space?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
- **Cross-reference:** Q-040 (gate dual-use topology), Q-043 (station internal transit)
---
*28 questions (7 resolved, 1 partially resolved, 20 open). Last updated: 2026-02-25 (Q-028 resolved → D-084)*
*44 questions (11 resolved, 2 partially resolved, 31 open). Last updated: 2026-02-25 (Q-040, Q-041, Q-043, Q-044 resolved; Q-042 partially resolved — Station District Layout Workshop #153 Round 3)*
+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)
- **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
```bash
make test # Run all tests
make test-server # cargo test in server/
make test-client # gdUnit4 tests (headless runner pending)
make test # Run all tests (test-server + test-client)
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
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).
### Cross-Encoder Fixtures
Binary file not shown.
+3 -1
View File
@@ -144,7 +144,9 @@ NPCs may reference locations and entities beyond Sova Station. These are real bu
**Other Krenn System stations:** The Krenn System has two other smaller orbital facilities (mining support station and an administrative relay). They're referenced occasionally in news tickers and operational scheduling. Not relevant to v0.1.
**The horizon gate:** Sova Station has a connection to the Reach's horizon gate network — the interstellar transport infrastructure. The horizon gate terminal is in the Administrative Hub district, not the Transit District. Characters with legitimate need can book transit to other systems. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
**The Krenn Ring (horizon station):** The Krenn System's interstellar connection is the Krenn Ring — a horizon station at approximately 800 AU from the Krenn star, accessible by system vessel (~4–6 days from Station Sova). Horizon stations are ancient orbital installations of unknown origin, self-maintaining, each containing multiple gate apertures connecting to other star systems. The Krenn Ring is not on Station Sova; it is a separate installation in the outer system.
**The Administrative Hub's interstellar transit facility:** Sova Station's Administrative Hub district houses the transit processing facility for interstellar travel — customs clearance, booking offices, and the shuttle dock for vessels heading to the Krenn Ring. When NPCs or documents refer to "the horizon gate terminal," they mean this processing facility, not a gate aperture on the station itself. Characters with legitimate need book transit here, then travel by shuttle to the Krenn Ring to board. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
---
+316
View File
@@ -0,0 +1,316 @@
# Spatial Layout: Gate Cluster (Span Gate Processing Facility)
**Ticket:** #153
**Date:** 2026-02-25
**Author:** Araminta (Visual Designer)
**Status:** v0.1 — wireframe quality, unblocks copy and gate cluster NPC authoring
**Grid:** 1 cell = 1m visual tile (visual grammar §2.1). Simulation operates at 0.5m; each visual tile = 2×2 sim tiles.
**Map area:** 40m wide × 32m deep (40×32 visual tiles) + observation gallery on z=2
**Zone palette:** Cool institutional grey — Era 3 construction, Commission-grade maintenance (visual grammar §1.1)
---
## Spatial Character
The gate cluster is the newest structure in the Transit District. Era 3 construction: clean sightlines, uniform LED-white overhead lighting, minimal accumulated grime. Commission-monitored and Commission-maintained. Where the Terminal reads as institutional but worn, and the Bar as warm and accumulated, the gate cluster reads as **administered**. The architecture communicates that someone is watching.
The building is a funnel. The span gate aperture (~15–20m diameter ring) determines the widest point; the passenger and freight flows narrow through processing stages; they emerge into the gate concourse, which opens outward as public space. The spatial logic is deliberate: volume at intake, compression through customs, expansion at public exit.
Dual-use scheduling is the spatial and operational premise (D-095): freight windows and passenger windows share the single aperture. The "flicker" (90-second mode transition between sequences) is legible to experienced travelers — different lighting cues on the aperture chamber walls mark which mode is active.
**G-11 entry note:** The detective arrives via this cluster from a Commission shuttle. Workers arrive via the transit platform on the bar side. These are structurally separated entry vectors. The gate cluster is the detective's first experience of the district.
---
## Floor Plan
### z=1 (Ground Floor)
```
N (span gate aperture — external, connects to The Ring)
|
1111111111222222222233333333334444444
1234567890123456789012345678901234567890
############[APERTURE RING]######### row 01 <- span gate ring (structural boundary)
# . . . APERTURE CHAMBER . . . # row 02
# . . . . . . . . . . . . . . # row 03 ACCESS: RESTRICTED
# . . . . . . . . . . . . . . # row 04 (airlock/transition zone)
########[D]##########[D]############ row 05 <- chamber exit doors (freight W, passenger E)
############################[D]###### row 06 <- freight staging north wall (east door = PAB)
# FREIGHT STAGING # PAB # row 07
# [FK][FK] [FK][FK] . # . # row 08 ACCESS: private (freight) / semi-public (PAB)
# [FK][FK] [FK][FK] . # . # row 09 PAB = Passenger Arrival Buffer
# [FK][FK] [FK][FK] . # . # row 10
# . . . . . . . . [CT][CT] # . # row 11 <- CT = cargo transporter dock points
# . . . . . . . . [CT][CT] # . # row 12 <- PAB merges south into customs at row 13
#####[D]####################[D]###### row 13 <- into customs lanes
###################[D]############### row 14 <- freight customs north entry
# FCL | FCL | FCL | FCL | FCL # row 15 ACCESS: semi-private (freight customs)
# [TS] | [TS] | [TS] | [TS] | [TS] # row 16 FCL = freight customs lane (5 lanes × 4vt)
# || | || | || | || | || # row 17 TS = terminal/scanner station per lane
# || | || | || | || | || # row 18 || = cargo lane (4vt wide, column breaks Q4)
# [P] | [P] | [P] | [P] | [P] # row 19 P = pillar/LOS anchor (4-tile interval)
# . . .|. . . |. . . |. . . |. . . # row 20 <- inspection floor
#######|#######[D]####[D]###|######## row 21 <- customs south wall; PCL entry
# PCL PCL PCL PCL PCL # row 22 ACCESS: semi-public (pedestrian customs)
# [TS] [TS] [TS] [TS] [TS] . . # row 23 PCL = pedestrian customs lanes (3 lanes × 2vt)
# [P] . . [P] . . [P] . . # row 24 <- queue markers + pillar anchors
# . . . . . . . . . . . . . . . # row 25
# . . . . . . . . . . . . . . . # row 26
############[D]####[D]############### row 27 <- customs south doors to concourse
#################################### row 28 <- concourse north wall
# . . [B] [B] . . [NT][NT] # row 29 ACCESS: public
# . . . . . . . . . . # row 30 B = bench, NT = news ticker
# . . [B] [B] . . . . . # row 31
# . . . . . . . . [D]SC # row 32 <- staircase (SC) east end; Commission entry
#################################### row 33 <- concourse south wall (district entry facade)
|
S (district interior — Terminal forecourt, transition corridor)
```
**Legend:**
```
# Wall (solid, blocks LOS and movement)
. Open walkable floor
[D] Doorway (traversable)
[APERTURE RING] Span gate ring structure (impassable during transit; open between sequences)
FCL Freight customs lane
PCL Pedestrian customs lane
[TS] Terminal/scanner station (customs clerk workstation)
[FK] Freight staging kiosk / forwarder terminal
[CT] Cargo transporter dock point (loading/unloading position)
[B] Bench (public seating)
[NT] News ticker display (wall-mounted)
[P] Structural pillar (LOS anchor, column break, gallery support above)
SC Staircase to z=2 observation gallery (east end of concourse)
PAB Passenger Arrival Buffer (east of freight staging, rows 06–12)
```
---
### z=2 (Observation Gallery) — Commission-only
```
N
|
(above customs lanes — rows 14–27 below)
1111111111222222222233333333334444
1234567890123456789012345678901234567
[GALLERY NORTH RAIL — partial glass/grating]
################################# <- gallery west and east walls
# . . . . GALLERY FLOOR . . . # Commission-only: pristine near-white
# [DK][DK] . . . [DK][DK] # DK = observation desk / surveillance kit
# . . . . . . . . . . . . . . # floor: #d4d8dc
# . . . . . . . . . . . . . . # walls: #e0e4e8
# [DK][DK] . . . [DK][DK] #
# . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . #
################################# <- gallery south rail (partial glass/grating)
|
[SC] staircase descends to z=1 concourse east end
|
S
```
**Gallery dimensions:** 32m wide × 10m deep (32×10 visual tiles). Positioned above the customs lanes (z=1 rows 14–27) and NOT above the concourse or staging zones.
**Cross-z LOS:** Gallery rail is transparent low wall (glass or metal grating). Observer on z=2 has LOS downward to z=1 customs lanes. Upward LOS from z=1 is blocked except at the staircase opening. Players cannot see gallery occupants from the customs floor unless standing at the staircase.
**Gallery floor is the customs ceiling** — approximately 4m structural clearance below.
---
## Zone Breakdown
### Zone 1 — Aperture Chamber (rows 01–05)
**Dimensions:** 40×4 visual tiles
**Access tier:** Restricted (Commission control + gate authority; no public entry)
**Purpose:** The transition space between the span gate aperture and the main processing facility. All passengers and freight pass through here immediately after emerging from the span gate. The aperture ring is the physical gate structure — when a transit sequence is active, the ring glows with transit residue (lighting cue for mode). Between sequences, the ring is dark and cold.
**NPC traffic:** Gate authority staff (2–3 stationed here per sequence). Arrivals flow through continuously during an active sequence; zero traffic between sequences.
**LOS notes:** The chamber is enclosed. No LOS to any other zone except the two exit doors (row 05). Gate authority staff can observe the full chamber volume. No LOS from the staging zones into the chamber.
**Key feature:** The "flicker" — the 90-second mode transition between freight and passenger sequences — is physically visible here. Lighting shifts, personnel rotate, cargo equipment is cleared or staged. An observer in the gate concourse (south) can hear the mode change but not see it.
### Zone 2 — Freight Staging (rows 06–13, west 24 tiles)
**Dimensions:** 24×8 visual tiles
**Access tier:** Private (authorized freight operators and customs personnel only)
**Purpose:** Where inbound freight is offloaded, registered, and staged for the customs inspection lanes. [FK] forwarder terminals are where freight agents log their manifest declarations before the cargo moves to the lanes. [CT] dock points are active during freight windows; they are dormant (low power, no staff) during passenger windows.
**NPC traffic:** Busy during freight windows. The operations manager (Triangle NPC) is typically here during active freight sequences — their role is coordinating flow from aperture to customs. Senior freight handlers work the dock points. Sparse during passenger windows.
**LOS notes:** Full LOS across the staging floor from the forwarder terminals. The freight customs entry door (row 13) is visible from the staging area. The PAB door (east) is visible but the PAB interior is not.
**Key feature:** The operations manager's position here — with LOS to the aperture chamber exits, the staging floor, and the customs entry — is the spatial expression of their authority. They see everything that comes in.
### Zone 3 — Passenger Arrival Buffer (rows 06–12, east 12 tiles)
**Dimensions:** 12×8 visual tiles
**Access tier:** Semi-public (arriving passengers only; no unauthorized entry from district side)
**Purpose:** Where passengers emerging from the span gate are held in a staging queue before processing through pedestrian customs. Separate from freight staging — the physical separation is the architectural enforcement of D-095's dual-use windows. During a freight window, the PAB is closed; during a passenger window, it fills.
**NPC traffic:** Moderated by gate sequence. Full during a passenger window; empty between or during freight windows.
**LOS notes:** LOS within the PAB is full. No direct LOS from the district concourse into the PAB — the customs lanes form a visual barrier. A player in the concourse sees only the south face of the customs lane structure.
**Key feature:** The detective's entry experience begins here (G-11). Arriving via Commission shuttle during a passenger window, they queue briefly before being waved through customs (or escorted directly to the observation gallery — see staircase at z=1 east end).
### Zone 4 — Freight Customs Lanes (rows 14–21, west 20 tiles)
**Dimensions:** 20×10 visual tiles (5 lanes × 4vt each, within a 20vt-wide zone, rows 14–21)
**Access tier:** Semi-private (freight operators entering the district; customs clerk staff)
**Purpose:** Processing incoming freight through customs inspection. Five lanes, each 4 visual tiles wide, each staffed by a customs clerk at a [TS] terminal station. [P] pillars at 4-tile intervals serve dual purpose: LOS anchors for the customs floor and structural supports for the observation gallery above.
**NPC traffic:** Customs clerks (5, one per lane) are stationed here during freight windows. The Commission inspector (Triangle NPC) circulates among lanes — their social dynamic with the clerks is expressed spatially by where they position themselves during inspections. During passenger windows, lanes are closed (screens down, no staff).
**LOS notes:** Clear LOS along each lane from north wall to south wall. The pillar breaks ([P]) interrupt cross-lane LOS at 4-tile intervals — an observer cannot see continuously across all 5 lanes. The gallery rail above (z=2) allows the Commission inspector to observe all lanes simultaneously from elevation. This is the critical asymmetry: floor-level observers have partial LOS; gallery observers have full LOS.
**Observation note:** The social triangle's power dynamic is visible in sightlines. The Commission inspector from the gallery sees the customs clerks in their entirety, including which freight forwarders are waved through vs. searched. The floor-level operations manager sees individual lanes but not the full picture. The detective, arriving from the gallery, can observe the customs floor before descending.
### Zone 5 — Pedestrian Customs Lanes (rows 22–27, east 12 tiles)
**Dimensions:** 12×10 visual tiles (3 lanes × 2vt each, with queue space to east, rows 22–27)
**Access tier:** Semi-public (arriving passengers processing into the district)
**Purpose:** Processing arriving passengers through customs. Three lanes, each 2 visual tiles wide, each with a [TS] scanner station. Queue space runs east of the lane structure. Simpler operation than freight customs — personal items scan, biometric check, manifest tag if applicable.
**NPC traffic:** Active only during passenger windows. During freight windows, the customs clerks from PCL rotate to assist with FCL overflow. The Commission inspector may operate from PCL during passenger windows if intelligence suggests surveillance value.
**LOS notes:** Narrower lanes mean LOS is more constrained. An observer in the queue can see only the lane directly ahead. From the gate concourse (south), the south face of the customs structure presents as a low partition — passengers emerging from customs are visible from the concourse immediately on exit.
**Key feature:** This is where observable inequity happens. The Commission inspector (or a directive they issue) results in one class of traveler being waved through while another is searched. This is visible to anyone in the concourse queue area — including the detective. The spatial proximity of the PCL south wall to the concourse benches [B] means concourse passengers witness the processing of arrivals.
### Zone 6 — Gate Concourse (rows 28–33)
**Dimensions:** 40×8 visual tiles (full building width)
**Access tier:** Public (all district residents, workers, and new arrivals)
**Purpose:** The public-facing terminus of the gate cluster. Benches [B] for waiting passengers, news ticker [NT] on the east wall for transit schedules and general news, and the primary facade opening to the district south. The staircase (SC) at the east end is the access point to the observation gallery — it is Commission-coded at the base (a discreet panel, not a visible barrier).
**NPC traffic:** Variable. Busy when a passenger sequence has just completed (arrivals dispersing). Sparse during freight windows (only workers and officials). The concourse is the natural convergence zone for all district-side personnel who have business at the gate cluster.
**LOS notes:** Full east-west LOS across the concourse. The [P] pillars from the customs lanes above do not extend to the concourse floor — the south edge of the customs structure is a visual wall at row 27. From the benches, observers can see the customs exit doors (row 27) and watch arrivals emerge. Cannot see into customs lanes from bench positions.
**Key feature:** The social reading zone on arrival. New arrivals (including the detective on their first visit) experience the concourse before moving into the district. The news ticker is a topic generator. The Commission staircase (east end) is present but low-key — coded access does not broadcast itself in Commission-grade facilities.
### Zone 7 — Observation Gallery (z=2, above zones 4–5)
**Dimensions:** 32×10 visual tiles (above the full customs lane section)
**Access tier:** Commission-only (staircase coded at z=1 east end of concourse)
**Purpose:** The gallery is where the Commission inspector works during active processing sequences. From here, all freight and pedestrian customs lanes are simultaneously observable. Observation desks [DK] with surveillance kit allow real-time customs monitoring, camera feed access, and communication with gate authority staff below. This is the institutional oversight position — the spatial embodiment of Commission authority over district entry.
**NPC traffic:** The Commission inspector during work hours. Possibly a second Commission observer (junior) — but sparse. This is not a social space; it is a surveillance position.
**LOS notes:** Full LOS down to all customs lanes (z=2 → z=1, through gallery rail). Partial LOS to freight staging (row 13 door visible from gallery north rail). NO LOS to aperture chamber (wall blocks), NO LOS to concourse (gallery south rail is opaque below rail height). Gallery interior has no LOS from the customs floor below — the cross-z asymmetry is deliberate and complete.
**Key feature:** The detective's first introduction to this space is via escort through the staircase. The experience of descending from gallery (full picture) to concourse (partial picture) is the spatial tutorial for the information asymmetry theme.
---
## Key Observation Positions
| Position | Code | LOS coverage | Why it matters |
|----------|------|-------------|----------------|
| Observation gallery (z=2, center) | POS-G1 | All freight + pedestrian customs lanes simultaneously | Commission inspector's domain. Highest-information position in the gate cluster. Asymmetric — not visible from below. |
| Freight staging floor (center) | POS-G2 | Aperture chamber exits, freight staging, customs entry door | Operations manager's natural position. Sees intake and output but not gallery or pedestrian lanes. |
| Gate concourse benches (rows 29-30, west) | POS-G3 | Customs exit doors (row 27), staircase base (east), full concourse | Player's first investigation position. Passive observation of arrivals emerging from customs and who accesses the staircase. |
| Pedestrian customs queue (row 22, east) | POS-G4 | PCL lanes, customs exit direction | Observer in queue can watch the customs clerks processing arrivals. Visible inequity in who is waved through vs. searched. |
| Concourse east end (near staircase) | POS-G5 | Staircase access panel, anyone ascending/descending | Monitoring staircase access reveals Commission movement. Coded panel is discreet but observable. |
| Gallery north rail (z=2) | POS-G6 | Freight staging floor through rail, customs lane north entries | Extended north-viewing position from gallery — tracks cargo from aperture exit to lane entry. |
---
## Sightline Analysis
```
FROM → Aperture Frt.Stg. PAB Frt.Cust. Ped.Cust. Concourse Gallery
TO ↓
Aperture SELF via door via door NO NO NO NO
Frt.Staging via door SELF via door via door NO NO NO
PAB via door via door SELF NO NO NO NO
Frt.Customs NO via door NO SELF NO via door rail(z2→z1)
Ped.Customs NO NO NO NO SELF via door rail(z2→z1)
Concourse NO NO NO via door via door SELF NO
Gallery NO rail(N) NO rail(full) rail(full) NO SELF
rail(z2→z1) = LOS from gallery down through transparent rail/grating
rail(N) = gallery north rail has partial LOS to freight staging floor
NO = wall or z-gap blocks
via door = LOS when door open
```
**Critical sightline: Gallery → all customs lanes**
The Commission inspector on z=2 has full LOS over every freight and pedestrian customs lane simultaneously. No position on the z=1 customs floor achieves equivalent coverage. This asymmetry is the spatial expression of institutional oversight.
**Critical sightline gap: Concourse → customs interior**
The concourse benches are south of the customs structure. The customs south wall (rows 14–21 for freight, 22–27 for pedestrian) presents as a visual barrier. A player on the benches sees the customs exit doors and emerging arrivals — but not what happens inside the lanes. Investigation of customs behavior requires entering the lanes or reaching the gallery.
**Critical sightline gap: Gallery → concourse**
The gallery south rail is opaque below the rail height. The Commission inspector cannot observe the concourse from the gallery without descending. The gallery is a surveillance position for entry processing, not for the public space.
---
## Access Tier Map
```
RESTRICTED PRIVATE SEMI-PRIVATE SEMI-PUBLIC PUBLIC
────────── ─────── ──────────── ─────────── ──────
Aperture Freight staging Freight customs Ped. customs Concourse
chamber (auth. operators) lanes lanes (all)
(clerks + (arriving
[Gallery z=2: freight ops) passengers)
Commission-only]
```
Sequential access enforcement (H-06): A freight operator moving from aperture to district must pass through freight staging → freight customs → concourse. No spatial path skips a tier. Pedestrian arrivals pass through PAB → pedestrian customs → concourse. The two flows are physically separated (west half vs. east half of the building) and join only at the concourse.
---
## NPC Traffic Density Annotations
| Time | Aperture | Frt. Staging | PAB | Frt. Customs | Ped. Customs | Concourse | Gallery |
|------|----------|-------------|-----|-------------|-------------|-----------|---------|
| Dawn (05-07) | very sparse | very sparse | closed | closed | closed | very sparse | — |
| Freight window 1 (07-12) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
| Passenger window (12-14) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
| Freight window 2 (14-19) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
| Passenger window (19-20) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
| Evening sparse (20-23) | sparse | sparse | closed | sparse | closed | sparse | varies |
| Night (23-05) | very sparse | very sparse | closed | closed | closed | very sparse | — |
**Flicker windows:** 90-second transition between freight and passenger modes. During this window:
- Aperture chamber resets (personnel exchange, lighting shifts, cargo equipment cleared or staged)
- All customs lanes briefly closed
- Concourse becomes transiently busier as travelers waiting for mode completion gather
- The operations manager is most exposed — coordinating the reset, moving between staging and customs entry
**Detective entry:** Commission shuttles arrive during passenger windows as a matter of protocol. First contact with the district begins in the aperture chamber, proceeds to the PAB, and typically diverts to the gallery staircase before customs processing is required.
---
## Narrative Triangle Service Notes
### Triangle 5 — Gate Authority (Operations Manager – Senior Freight Handler – Commission Inspector)
This is an institutional-oversight triangle, structurally different from the Terminal's knowledge-and-leverage triangles (1–2) and the Bar's social-loyalty triangles (3–4).
- **Operations manager:** Their domain is the freight flow — aperture to staging to customs. They have private-tier access everywhere on the z=1 floor. They are measured by throughput: how much cargo clears customs in a window. They have an accommodation relationship with certain freight forwarders (see customs inequity below).
- **Senior freight handler:** The forwarder who benefits from that accommodation. They know what they get, they know why, and they know the operations manager knows they know. This is the stable complicity leg of the triangle.
- **Commission inspector:** Their domain is the gallery. They watch the customs floor from above. They may know about the accommodation, or may be about to discover it, or may be using it as leverage already. Their relationship to the operations manager is formally collaborative, actually adversarial.
**Spatial expression:** The operations manager never goes to the gallery. The Commission inspector rarely comes to the floor. The senior freight handler is on the floor. The triangle's tension is mediated by the cross-z sightline — the inspector can see the forwarder being waved through, and the operations manager knows the inspector is watching, but neither will acknowledge it in the same zone at the same time.
**Observable inequity (investigation entry point):** A player watching from the concourse benches (POS-G3) or from the pedestrian customs queue (POS-G4) can observe a freight forwarder being waved through freight customs without search while a commuter on the pedestrian side receives a full scan. This is not dramatic — it reads as normal. The player has to make the connection: waved through = known cargo = manifested incorrectly = this is where the lattice components enter.
**Tension staging locations:**
- Freight staging floor (operations manager's ground; conversations here are authority-neutral)
- Gallery (inspector's ground; a summons to the gallery is pressure)
- Customs lane (the observable action space; what clerks actually do is determined by unspoken directives from above)
- Concourse east end near staircase (the transition space; anyone ascending the staircase must pass anyone watching the staircase)
---
## Z-Level Notes
**z=0:** Not present in the gate cluster. Maintenance access to the gate cluster, if any, is via the district maintenance spine (z=0 elsewhere in district) and does not extend into the gate cluster interior. Era 3 construction has no maintenance corridor integration — maintenance occurs from above via service panels.
**z=1:** All gate cluster zones: aperture chamber, freight staging, PAB, freight customs, pedestrian customs, concourse. All NPCs and player movement on this level.
**z=2:** Observation gallery only. Staircase is the sole connection point (z=1 concourse east end ↔ z=2 gallery). Commission-coded access panel at base of staircase is present but low-profile (no visible lock, no visible panel labeling in Era 3 style — access is granted by insertion of Commission neural-tag proximity, not a key).
**Cross-z visibility rules:**
- Gallery → customs lanes: full downward LOS through transparent rail/grating
- Customs lanes → gallery: no upward LOS (gallery floor solid except rail; rail height above standing head height)
- Staircase opening: local LOS only (see who is at the staircase base or top, not into gallery interior)
---
## Notes for Copy Team
1. **Aperture chamber is the in-world ritual.** Arriving via span gate is Commonwealth-mundane but the aperture ring has residual energy effects — ambient hum, slight color temperature shift as light normalizes from transit. Monologue lines for the detective's arrival should note this. Standard sensory detail for immersive-world arrivals.
2. **The customs inequity is not dramatic.** When the senior freight handler is waved through, it should read as routine from NPC behavior — a nod, a scan confirmed, the lane opens. Overheard dialogue, if any, should be procedural: manifest check language, not conversational. The player learns that something is wrong from the pattern, not from a flagrant scene.
3. **The Commission inspector's gallery is their professional comfort zone.** Dialogue set in the gallery (if the detective accesses it) should reflect this — the inspector is at ease up here, slightly less guarded. On the floor, they are performing authority. In the gallery, they are just watching.
4. **The operations manager on the freight staging floor.** This is their element. Logistics language, shorthand with the senior freight handler. Any casual conversation with the detective here is the operations manager on home turf — helpful enough, not forthcoming.
5. **The flicker is an ambient event.** Travelers who know the schedule stop and wait. Travelers who don't know find themselves in a 90-second limbo — nothing is moving, customs is closed. The concourse fills briefly. Use this as a social compression beat: forced proximity, idle waiting, overheard conversations that wouldn't happen mid-flow.
6. **The staircase is visible, not obvious.** In Era 3 design language, it is clean, architectural, slightly more refined than the surrounding fittings. It doesn't broadcast Commission. Players who are paying attention will notice it; players who aren't will miss it. Second visit = "wait, I didn't see that last time."
+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-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-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": {
"type": "Rectangle",
"left": 140, "top": 116, "width": 120, "height": 32,
"fillColor": "#1a2030",
"strokeColor": "#c8d0e0",
"fillColor": "#0d1018",
"strokeColor": "#333340",
"corners": [2, 2, 0, 0]
},
"tab-save-text": {
"type": "Text",
"left": 156, "top": 125,
"text": "SAVE",
"fontColor": "#c8d0e0",
"fontColor": "#556677",
"fontSize": 13
},
"tab-load": {
"type": "Rectangle",
"left": 264, "top": 116, "width": 120, "height": 32,
"fillColor": "#0d1018",
"strokeColor": "#333340",
"fillColor": "#1a2030",
"strokeColor": "#c8d0e0",
"corners": [2, 2, 0, 0]
},
"tab-load-text": {
"type": "Text",
"left": 280, "top": 125,
"text": "LOAD",
"fontColor": "#556677",
"fontColor": "#c8d0e0",
"fontSize": 13
},
"save-slot-1-active": {
"game-1-header": {
"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",
"strokeColor": "#c8d8f0",
"corners": [2, 2, 2, 2]
},
"save-slot-1-accent": {
"qs-accent": {
"type": "Rectangle",
"left": 140, "top": 156, "width": 3, "height": 72,
"left": 158, "top": 192, "width": 3, "height": 62,
"fillColor": "#c8d8f0",
"strokeColor": "#c8d8f0"
},
"slot-1-date": {
"qs-label": {
"type": "Text",
"left": 152, "top": 162,
"text": "AUTOSAVE // Day 1, 07:42",
"left": 170, "top": 200,
"text": "QUICKSAVE // Day 3, 14:22",
"fontColor": "#c8d0e0",
"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": {
"type": "Text",
"left": 152, "top": 180,
"text": "The Terminal — morning shift // Detective",
"fontColor": "#8899aa",
"left": 170, "top": 354,
"text": "Hab quarters \u2014 evening",
"fontColor": "#556677",
"fontSize": 12
},
"slot-1-timestamp": {
"type": "Text",
"left": 152, "top": 198,
"text": "saved: 2026-02-23 14:31",
"fontColor": "#556677",
"left": 170, "top": 370,
"text": "saved: 2026-02-25 14:48",
"fontColor": "#3a4455",
"fontSize": 11
},
"slot-1-actions": {
"type": "Text",
"left": 860, "top": 175,
"text": "[Enter] Overwrite / Load",
"fontColor": "#c8d8f0",
"fontSize": 12
},
"save-slot-2": {
"game-2-header": {
"type": "Rectangle",
"left": 140, "top": 236, "width": 860, "height": 72,
"fillColor": "#0e1118",
"left": 140, "top": 404, "width": 860, "height": 32,
"fillColor": "#111520",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"slot-2-date": {
"game-2-title": {
"type": "Text",
"left": 152, "top": 250,
"text": "SLOT 2 // Day 1, 06:15",
"left": 152, "top": 413,
"text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday",
"fontColor": "#8899aa",
"fontSize": 13
},
"slot-2-location": {
"type": "Text",
"left": 152, "top": 268,
"text": "Arrival — entering Sova Transit // Detective",
"fontColor": "#556677",
"fontSize": 12
},
"slot-2-timestamp": {
"game-2-count": {
"type": "Text",
"left": 152, "top": 286,
"text": "saved: 2026-02-23 13:10",
"left": 920, "top": 413,
"text": "2 saves",
"fontColor": "#3a4455",
"fontSize": 11
},
"save-slot-3": {
"game-3-header": {
"type": "Rectangle",
"left": 140, "top": 316, "width": 860, "height": 72,
"fillColor": "#0e1118",
"left": 140, "top": 444, "width": 860, "height": 32,
"fillColor": "#111520",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"slot-3-date": {
"game-3-title": {
"type": "Text",
"left": 152, "top": 330,
"text": "SLOT 3 // Day 1, 07:30",
"left": 152, "top": 453,
"text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20",
"fontColor": "#8899aa",
"fontSize": 13
},
"slot-3-location": {
"type": "Text",
"left": 152, "top": 348,
"text": "Corridor B-7 — Kael spotted // Smuggler",
"fontColor": "#556677",
"fontSize": 12
},
"slot-3-timestamp": {
"game-3-count": {
"type": "Text",
"left": 152, "top": 366,
"text": "saved: 2026-02-23 12:48",
"left": 920, "top": 453,
"text": "5 saves",
"fontColor": "#3a4455",
"fontSize": 11
},
"empty-slots-label": {
"footer-note": {
"type": "Text",
"left": 140, "top": 400,
"text": "SLOTS 4-8 — empty",
"fontColor": "#2a3040",
"fontSize": 12
},
"save-note": {
"type": "Text",
"left": 140, "top": 660,
"text": "Autosave on: zone transitions, conversation ends, significant events",
"left": 140, "top": 660, "width": 860,
"text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload",
"fontColor": "#3a4455",
"fontSize": 11
"fontSize": 11,
"wordWrap": true
},
"annotation-title": {
"type": "Text",
"left": 16, "top": 720,
"text": "v0.1 SAVE / LOAD SCREEN",
"text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)",
"fontColor": "#556677",
"fontSize": 11
},
"annotation-notes": {
"type": "Text",
"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",
"fontSize": 10,
"wordWrap": true
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+235
View File
@@ -0,0 +1,235 @@
# District Topology — Sova Transit District
# D-093 spatial layout. D-094 hierarchy: 256×256 vt (4×4 blocks).
# North = span gate entry. South-east = tram entry.
direction: down
vars: {
bg: "#1a1e24"
txt: "#c8d0e0"
acc: "#c8d8f0"
pub: "#1a3320"
spub: "#2e2a10"
spriv: "#2e1a08"
priv: "#2a0e0e"
comm: "#0e1a2e"
neut: "#1e2228"
s-pub: "#3a8a50"
s-spub: "#b8a020"
s-spriv: "#c86010"
s-priv: "#c02020"
s-comm: "#3060c0"
s-neut: "#4a5060"
}
# ── LEGEND ──
legend: Legend {
style.fill: ${bg}
style.stroke: ${acc}
style.font-color: ${txt}
style.font-size: 11
direction: right
l1: Public { style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt} }
l2: Semi-pub { style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt} }
l3: Semi-priv { style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt} }
l4: Private { style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt} }
l5: Commission { style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt} }
}
# ── NORTH ENTRY: SPAN GATE ──
span_gate: The Ring\n(span gate aperture) {
shape: hexagon
style.fill: ${comm}
style.stroke: ${s-comm}
style.font-color: ${txt}
}
# ── GATE CLUSTER ──
gate: Gate Cluster · 40×32 vt {
style.fill: ${bg}
style.stroke: ${s-comm}
style.font-color: ${txt}
style.border-radius: 4
aperture: Aperture\n8×4 {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
staging: Freight Staging\n24×8 {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
pab: Passenger Arrival\n12×8 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
customs: Customs Lanes\n(freight 5×4vt + ped 3×2vt) {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
concourse: Concourse\n40×8 · PUBLIC {
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
}
gallery: Gallery · z=2\n32×10 · COMMISSION {
style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt}
}
aperture -> staging: freight { style.stroke: ${s-priv} }
aperture -> pab: passenger { style.stroke: ${s-spub} }
staging -> customs { style.stroke: ${s-spriv} }
pab -> customs { style.stroke: ${s-spub} }
customs -> concourse { style.stroke: ${s-pub} }
concourse -> gallery: "staircase (Commission)" {
style.stroke: ${s-comm}; style.stroke-dash: 4
}
gallery -> customs: "LOS z=2 down" {
style.stroke: ${s-comm}; style.stroke-dash: 4
}
}
span_gate -> gate.aperture: "dual-use transit\n(90s flicker)" {
style.stroke: ${s-comm}
}
# ── TERMINAL ──
terminal: Terminal · 44×28 vt {
style.fill: ${bg}
style.stroke: ${s-spriv}
style.font-color: ${txt}
style.border-radius: 4
forecourt: Forecourt\n44×4 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
cargo: Cargo Floor + Main Corridor {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
storage: Restricted Storage\n(single coded door) {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
hatch_t: M-HATCH-T {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
forecourt -> cargo { style.stroke: ${s-spriv} }
cargo -> storage: "coded door" { style.stroke: ${s-priv} }
storage -> hatch_t { style.stroke: ${s-priv} }
}
gate.concourse -> terminal.forecourt: "district spine (south)" {
style.stroke: ${s-pub}
}
# ── TRANSITION CORRIDOR ──
corridor: Transition Corridor\n~40×6 vt {
style.fill: ${spub}
style.stroke: ${s-spub}
style.font-color: ${txt}
style.border-radius: 4
}
terminal.cargo -> corridor: "public route" {
style.stroke: ${s-spub}
}
# ── BAR ──
bar: The Last Shift · 28×22 vt {
style.fill: ${bg}
style.stroke: ${s-pub}
style.font-color: ${txt}
style.border-radius: 4
approach: Bar Approach\n28×3 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
floor: Main Floor\n(corner booth · card table) {
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
}
bathroom: Bathroom Corridor\n(east ext.) {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
backroom: Back Room\n(Lera) {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
hatch_b: M-HATCH-B {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
approach -> floor { style.stroke: ${s-pub} }
floor -> bathroom: "east door" { style.stroke: ${s-spriv} }
floor -> backroom: "staff only" { style.stroke: ${s-priv} }
bathroom -> hatch_b { style.stroke: ${s-priv} }
}
corridor -> bar.approach: "bar-side decompression" {
style.stroke: ${s-spub}
}
# ── MAINTENANCE CORRIDOR (z=0) ──
maint: Maintenance Corridor\n(z=0 · Era 1 · 2vt wide)\nzero public traffic {
style.fill: ${priv}
style.stroke: ${s-priv}
style.font-color: ${txt}
style.border-radius: 4
style.stroke-dash: 5
}
junc: JUNC-1 {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
terminal.hatch_t -> maint: "z=1 down z=0" {
style.stroke: ${s-priv}; style.stroke-dash: 5
}
maint -> junc { style.stroke: ${s-priv}; style.stroke-dash: 5 }
junc -> bar.hatch_b: "z=0 up z=1" {
style.stroke: ${s-priv}; style.stroke-dash: 5
}
# ── SOUTH-EAST ENTRY: TRAM ──
the_loop: The Loop\n(station tram · 6 districts) {
shape: hexagon
style.fill: ${neut}
style.stroke: ${s-neut}
style.font-color: ${txt}
}
platform: Transit Platform\n~12×8 vt · bar-side\n(encounter node) {
style.fill: ${pub}
style.stroke: ${s-pub}
style.font-color: ${txt}
style.border-radius: 4
}
the_loop -> platform: "workers arrive here (G-11)" {
style.stroke: ${s-neut}
}
platform -> bar.approach: "adjacent" {
style.stroke: ${s-pub}
}
# ── SECTOR 3 ──
sector3: Sector 3 Residential\n~15×12 vt · Drin/Naia {
style.fill: ${spub}
style.stroke: ${s-spub}
style.font-color: ${txt}
style.border-radius: 4
}
sector3 -> terminal.forecourt: "near Terminal" {
style.stroke: ${s-spub}; style.stroke-dash: 3
}
sector3 -> maint: "adjacent to spine" {
style.stroke: ${s-neut}; style.stroke-dash: 3
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

+1
View File
@@ -23,3 +23,4 @@ Historical discussion rounds from the Commonwealth game design process.
| 17 | Content Architecture | D-023, D-024, D-025, D-026, D-027, D-028, D-029 | [round-17](round-17-content-architecture.md) |
| 18 | v0.1 Gap Analysis Workshop | D-030, D-031, D-032, D-033, D-034, D-035, D-036, D-037, D-038, D-039, D-040 | [round-18](round-18-v01-gap-analysis-workshop.md) |
| 19 | Knowledge Graph & Information Boundaries Workshop | D-041, Q-016 resolved, Q-019 partially resolved, Q-024, Q-025, Q-026 | [workshop brief](../workshops/knowledge-graph-information-boundaries/workshop-brief.md), [synthesis](../workshops/knowledge-graph-information-boundaries/round2-synthesis.md) |
| 20 | Station District Layout Design (Workshop #153) | D-093, D-094, D-095; Q-040–Q-044 resolved/partially resolved | [round-20](round-20-station-district-layout.md) |
@@ -0,0 +1,663 @@
# Round 20: Station District Layout Design — Ticket #153
**Sprint:** 20 (Shape)
**Date:** 2026-02-25
**Ticket:** #153 — Station district layout design
**Participants:** Gestalt, Miri, Araminta, Tyre, Paula, Ozzie, Qatux (documenter)
**Output target:** D-record in `decisions/content.md` or `decisions/architecture.md`
**Blocks:** #155, #188
---
## Internal Round Structure
| Internal Round | Topic | Status |
|----------------|-------|--------|
| Round 1 | Inventory and constraints | Complete — see §1 below |
| Round 2 | Topology proposals | Pending |
| Round 3 | Convergence and D-record draft | Pending |
---
## §1 — ROUND 1: Constraints Summary
*Compiled by Qatux. Derived from 6 agent contributions: Ozzie, Gestalt, Miri, Tyre, Araminta, Paula.*
**Lead note (pre-round):** The spatial patterns decided here establish the district template that Q-036's generator will eventually use. Decisions here become architectural precedent — not just v0.1 configuration.
**Qatux framing:** Constraints below are tagged:
- `[v0.1]` — specific to the hand-authored Sova Transit District
- `[template]` — generalisable to any future generated district of this type
- `[both]` — applies at both levels
Cross-reference: Q-036 (district skeleton as generator output) tracks where template decisions need formal specification.
---
### 1. Hard Constraints
*Non-negotiable: confirmed decisions, confirmed technical facts, Paula's structural requirements that block narrative arcs if violated.*
| # | Constraint | Source | Tag | Cross-ref |
|---|------------|--------|-----|-----------|
| H-01 | Dual-scale grid: 0.5m sim tiles, 1m visual tiles. All tile counts in this document are **visual tiles** unless noted. | D-066 | both | D-066 |
| H-02 | Tile-based movement. All spatial reasoning is discrete. Corridors must be ≥1 visual tile wide; functional spaces ≥2 tiles wide. | D-054 | both | D-054 |
| H-03 | Fog zone temperature tint is already decided: Terminal = cool dark, Bar = warm dark, corridors = neutral dark. The gate cluster adds a fourth zone requiring a tint assignment. | D-059 | v0.1 | D-059 |
| H-04 | Local map budget: ~150×150 tiles. Tyre confirms current layouts use ≤10% of this budget. Performance is not a binding constraint at current scope. | D-014, Tyre | both | D-014 |
| H-05 | Social sites must be connected spaces of 15–40 tiles. | D-025 | template | D-025 |
| H-06 | Access tiers must be traversed **sequentially** — public → semi-public → restricted. No spatial path may allow skipping a tier. | Gestalt | template | D-025 |
| H-07 | Restricted storage has exactly **one** entrance (the coded door from the cargo floor). No second entrance, no back exit. | Paula | v0.1 | #311 |
| H-08 | Maintenance corridor has **zero LOS** from all other spaces. Its interior is not visible from any public or semi-public zone. Detection requires: sound propagation (footsteps on grating) or witness at hatch entry/exit points only. | Paula, smuggling layout | v0.1 | #313 |
| H-09 | No third route between Terminal and Bar. Exactly two routes exist: (a) the public transition corridor and (b) the maintenance corridor (ring-only, private). A third route would dissolve the ring's movement asymmetry. | Paula | v0.1 | #313 |
| H-10 | Bathroom corridor interior has **zero LOS** from the bar floor. Observer at bar can see only the door, not the interior. | Paula, bar layout | v0.1 | #312 |
| H-11 | No private path between manifest processing and supervisor's office. Maret's crossing of the main corridor is a public act with narrative cost — visibility is the spatial mechanism. | Paula, terminal layout | v0.1 | #311 |
| H-12 | Chunk size decision required. Tyre recommends **32×32 sim tiles** (= 16×16 visual tiles) as the generation unit. Zone and access-tier boundaries should align to chunk edges where possible. | Tyre | template | D-073, Q-036 |
| H-13 | Z-level allocation decision required. Tyre recommends maintenance corridor on **z=0**, bar/terminal structures on **z=1**. No additional z-levels without gameplay justification. | Tyre, Araminta | both | D-049 |
---
### 2. Setting Constraints
*What the Sova station profile and worldbuilding require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| S-01 | Span gate at Terminal's **north face**. Freight enters from north (inbound cargo from span gate); maintenance exits south. This is directional — north = external transit, south = district interior. | Miri, terminal layout | v0.1 |
| S-02 | **Two separate entry vectors** into the district: (a) freight span gate and (b) commuter transit connection. Passengers and freight do not share the same entry point. | Miri | template |
| S-03 | Sealed hull-section boundary. The district has a **finite, countable number of choke-point exits** — not open-ended. The player can know all exits. | Miri | template |
| S-04 | Sector 3 is a named sub-area of the district with an unresolved ventilation issue. Must be referenced spatially — it has a location, even if not a full social site. | Miri | v0.1 |
| S-05 | Maintenance spine is **Era 1 construction** — it predates the current buildings. The spine's route is fixed; buildings were placed around it. This explains: grating floors, cold-white sparse lighting, no Meridian coverage, and why the ring chose it. | Miri | v0.1 |
| S-06 | Meridian coverage follows construction era gradient: new construction (gate cluster) = good coverage, mixed era (Terminal, Bar area) = degraded, Era 1 (maintenance spine) = none. | Miri | template |
| S-07 | 800 permanent population = compact district. Walking distances are short. Everything is known. | Miri | v0.1 |
| S-08 | Back room alley exit leads to the **district edge** (maintenance alley). It is not an interior district route — it accesses the hull boundary, enabling exit without crossing public district space. | Paula, bar layout | v0.1 |
| S-09 | Voss does not appear at the bar. The spatial separation of Triangles 1/2 (Terminal-based) from Triangles 3/4 (Bar-adjacent) is architecturally enforced by Voss's absence from the bar. | Paula | v0.1 |
| S-10 | Maintenance corridor carries **zero regular traffic**. It must be architecturally believable that no worker has reason to enter — it connects restricted storage to the bar's bathroom corridor. That route has no legitimate use. | Paula | v0.1 |
---
### 3. Gameplay Constraints
*What the four gameplay loops require from the spatial layout.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| G-01 | District must support **four gameplay loops** simultaneously: investigation, social observation, smuggling, daily life. Each loop requires distinct spatial affordances that do not conflict. | Gestalt | template |
| G-02 | **Minimum 4–6 social sites** in the district. Confirmed: Terminal (1), Bar (1), Gate cluster (to design, 1). Remaining 1–3 sites are unspecified. | Gestalt | template |
| G-03 | **Minimum 2 NPC route convergence points** outside Terminal and Bar — locations where NPCs from different social sites share a path, enabling observation of cross-site relationships. | Gestalt | template |
| G-04 | Gate cluster requires its own **social triangle** (≥3 NPCs with conflicting interests). This is not just a transit hub — it is a social site with investigation affordances. | Gestalt | template |
| G-05 | **District entry = fork**. Player's first spatial decision is directional: left or right, Terminal or Bar side. Both directions are legitimate from moment one. No forced tutorial path. | Ozzie | template |
| G-06 | Transition corridor crossing: **20–25 seconds** at Walk stance. Long enough to be a temporal beat; short enough not to become friction. At Walk (1 tile / 2 ticks, 10 tps) = 5 tiles per second = 100–125 tiles at 20–25s. The corridor as designed (~40m = 40 visual tiles) achieves ~8 seconds — **this is a gap that needs resolving in Round 2** (see Open Tensions T-08). | Ozzie, smuggling layout | v0.1 |
| G-07 | Maintenance corridor is a **gradual unlock**, not a sudden discovery. Player should be able to infer its existence (sound, NPC behavior anomaly, spatial hint) before accessing it physically. | Ozzie | template |
| G-08 | Every ring-associated location must **read as mundane on first pass**. "Nothing looks wrong" must be achievable without prior knowledge. The ring's spatial design principle (from smuggling layout): same physical space, different player understanding. | Ozzie, smuggling layout | template |
| G-09 | Generator district skeleton = **social site positions + access tier topology + traffic routing + observation position set + chokepoint designation**. This is the minimum specification for a generated district to be functional for all four gameplay loops. | Gestalt | template |
| G-10 | Zone-chunk alignment: audio zone transitions (D-073) and access tier transitions should coincide with chunk boundaries where possible, enabling the generator to reason about zones at chunk granularity. | Tyre | template |
---
### 4. Narrative Constraints
*What the five triangle arcs require from the spatial layout.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| N-01 | Triangle 4 (Drin–System–Ring) **spans both buildings**. Sera Venn needs a Commission inspection presence at the Terminal — a legitimate reason to be there that doesn't read as suspicious. The spatial layout must provide a Commission inspection point in the Terminal district. | Paula | v0.1 |
| N-02 | No private path between manifest processing and supervisor's office (see H-11). Reiterated here: the spatial cost of Maret choosing to act is the *visibility* of crossing the main corridor. | Paula | v0.1 |
| N-03 | No second entrance to restricted storage (see H-07). The ring's chokepoint is architectural — not a character choice. | Paula | v0.1 |
| N-04 | Bathroom corridor interior zero LOS from bar floor (see H-10). Private ring exchanges in the corridor must be invisible to observers on the bar floor. | Paula | v0.1 |
| N-05 | Exactly two routes between Terminal and Bar (see H-09). The public corridor is the ring's exposure; the maintenance corridor is their bypass. A third route dissolves this asymmetry. | Paula | v0.1 |
| N-06 | Back room alley exit to district edge (see S-08). Enables ring operational flow from bar side without re-crossing bar floor. | Paula | v0.1 |
| N-07 | Voss stays in Terminal spatial zone (see S-09). Triangle 1 and Triangle 2 are Terminal dramas; Triangle 3 is a Bar drama. Voss's absence from the bar is what keeps them separate. | Paula | v0.1 |
| N-08 | Maintenance corridor must be genuinely zero-traffic (see S-10). If workers ever had legitimate reason to use it, the ring's use would not be anomalous. | Paula | v0.1 |
| N-09 | **Path B discovery** (exploration-heavy investigation path) requires the maintenance hatch to be **discoverable from the break room area**. The terminal layout shows break room (southwest) and restricted storage (south-center) as adjacent structures, but the hatch (M-HATCH-T) opens inside restricted storage, not the break room. This is Paula's key topological issue — see Open Tensions T-01. | Paula | v0.1 |
---
### 5. Player Experience Constraints
*What feel and navigation require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| P-01 | **Fork at district entry.** First decision is spatial. Terminal to the left, Bar to the right (or some equivalent directionality). The fork must be immediate and legible — no single entry corridor that forces the player through one building first. | Ozzie | template |
| P-02 | Transition corridor is a **tonal between-space** — not dead space. Moving through it is a beat of reflection: leaving one social temperature (Terminal = institutional cool), entering another (Bar = warm amber). 20–25s at Walk stance is the target duration. | Ozzie | template |
| P-03 | Main corridor at the Terminal must **feel dangerous**. Not literally — no combat threat — but the player should feel observed and out of place if they linger. NPC density, the supervisor's window, the chokepoint geometry all combine to produce this. | Ozzie | v0.1 |
| P-04 | Corner booth in the Bar is the **primary investigation observation post**. It must maintain LOS to: entrance, news ticker cluster, bar counter, card table, and back room door. Confirmed by bar layout. Any district modification that disrupts this LOS set breaks the investigation hub. | Ozzie, bar layout | v0.1 |
| P-05 | Maintenance corridor discovery should follow an **inference arc**: player hears something, notices NPC timing anomaly, finds the hatch, enters. Discovery should feel like a reveal, not a stumble. | Ozzie | template |
| P-06 | The "nothing looks wrong" moment — when the player first realizes the mundane spaces are the criminal infrastructure — should emerge from **accumulated observation**, not a single clue. Spatial design must support layered discovery: visit 1 = normal, visit 2 = curious, visit 3 = understood. | Ozzie | template |
---
### 6. Visual / Spatial Constraints
*What the tilemap, art direction, and zone palette require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| V-01 | Terminal facade (44m wide) requires a **forecourt** — breathing space between the building face and the transition corridor or district spine. The facade cannot abut a corridor directly. | Araminta | v0.1 |
| V-02 | Bar east extension (bathroom corridor, 6m) **faces north**. This fixes the bar's relative orientation: the bathroom corridor opens northward toward the main transit area. The alley exit (south door, row 20 in bar layout) faces the district edge. | Araminta | v0.1 |
| V-03 | Transition corridor requires **widening zones** at both ends — spatial decompression before entering the Terminal forecourt and before entering the Bar entry. Narrow corridor expanding to wide forecourt reads as "arrival." | Araminta | template |
| V-04 | Gate cluster zone palette: **coolest and newest** in district. Era 3 construction, Commission-grade maintenance. Visually distinct from Terminal (cool grey-navy) and Bar (warm amber). Exact palette TBD in Round 2. | Araminta | v0.1 |
| V-05 | **Minimum corridor widths** by type (exact values to be specified in Round 2): maintenance corridor = 2m (confirmed, smuggling layout), transition corridor = 6m (confirmed, smuggling layout), internal building corridors and secondary public corridors = TBD. | Araminta | template |
| V-06 | **LOS anchors every ~4 visual tiles** in open spaces. Large open areas (forecourt, cargo floor, bar main floor) need furniture, pillars, kiosks, or fixtures at regular intervals. These serve dual purpose: visual rhythm and gameplay cover/observation points. | Araminta | template |
| V-07 | No additional **physical z-levels** unless gameplay-justified. Maintenance corridor on z=0, bar/terminal on z=1. Multi-floor structures require a gameplay reason (investigation access to a floor, combat routing). Cosmetic vertical variation is not sufficient justification. | Araminta, Tyre | both |
| V-08 | Zone temperature tints (D-059) must be **distinct and non-overlapping at transition boundaries**. Crossfade handled by D-073 (1.5–2s audio tween at hard tile boundary). Visual fog tint transition should use the same boundary — player should not be in two zone temperatures simultaneously. | D-059, D-073 | template |
| V-09 | Generator parameterization: minimum viable generator parameters for spatial layout include **facade width ratio** (building width : forecourt depth), **corridor width minimums by type**, **LOS anchor interval** (tiles between anchor objects in open spaces). | Araminta | template |
---
### 7. Open Tensions
*Where constraints conflict or are underspecified — these are Round 2 discussion topics.*
---
#### T-01 — PRIORITY: Break Room Adjacency / Path B Discovery
**What's at stake:** The detective's exploration-heavy investigation path (Path B from smuggling layout) begins with a floor worker in the break room mentioning Kael's odd hours, then the detective physically discovering the maintenance hatch. But the hatch (M-HATCH-T) opens inside *restricted storage*, not the break room. The break room (terminal layout rows 22–26, west) is southwest; restricted storage (rows 27–30, south-center) is south-center. They are adjacent but not connected.
**Paula's three options:**
- **Option A — Shared wall with sound propagation.** Break room and restricted storage share an interior wall. A worker in the break room can hear sounds through the wall that hint at activity in the maintenance corridor (footsteps on grating). Player hears anomaly → investigates restricted storage → discovers hatch.
- **Option B — Disused side passage.** A disused (non-traversable) side passage between break room and restricted storage area gives physical proximity to the hatch area without creating a second route to restricted storage.
- **Option C — Path B starts on cargo floor.** A worker hears sounds near restricted storage while on the cargo floor (not the break room). Path B's starting location shifts from break room to cargo floor.
**Why it needs resolving before Round 3:** The option chosen affects the terminal layout (wall topology between zones) and the district layout (is the break room at the southwest corner, or does it need repositioning?). **This is the highest-priority Round 2 discussion item.**
**Generator implication [template]:** Which option generalizes? Option A (shared wall + sound propagation) is generalisable — it establishes "investigation paths can start with audio anomaly from adjacent zone." Options B and C are more specific to this layout.
---
#### T-02 — Gate Cluster Scope and Triangle
**What's at stake:** Gestalt requires the gate cluster to be a full social site with its own triangle (≥3 NPCs). Miri requires freight/commuter flow separation at the gate. Together these imply the gate cluster is substantial — a fourth major location, not just a corridor junction. But no scope has been defined: how large? How many zones? What triangle roles?
**The tension:** A large gate cluster is more content work than a small one. The district tile budget has room, but the content authoring budget (triangles, NPC lines) may not. Round 2 needs a concrete proposal for gate cluster size and triangle composition, or a decision to scope it down.
**Generator implication [template]:** Gate cluster = entry node in district skeleton. Every freight district has one. The question is whether entry nodes always carry a social triangle or whether that's optional. If Q-036's district skeleton requires a triangle at every social site (Gestalt's minimum is 4–6 sites with triangles), entry nodes need triangle support.
---
#### T-03 — Commuter Transit Entry Point Placement
**What's at stake:** Miri requires a separate commuter transit connection (not the span gate). This creates a second district entry point. Where it sits relative to the Terminal and Bar has large implications for NPC traffic patterns and ring exposure.
**Two principal options:**
- **Option A — Gate-cluster-integrated.** Commuter transit is adjacent to the span gate — same spatial cluster, separate lanes. All inbound traffic (freight and passenger) arrives in the same zone, then fans out. This simplifies district topology but creates a single convergence point that's easier to monitor.
- **Option B — Separate entry point, Bar-side.** Commuter transit hub is on the bar side of the district (near the bar, away from the terminal). Workers arrive near their social space, not their workplace. This produces two active entry zones and richer NPC traffic routing — but complicates ring exposure analysis.
**Generator implication [template]:** Two-vector entry is Miri's universal freight-district template. The generator needs to know whether the two vectors are co-located (same cluster) or distributed (separate district zones). This is a structural parameter.
---
#### T-04 — Remaining Social Sites (1–3 Unidentified)
**What's at stake:** Gestalt requires 4–6 social sites. Confirmed: Terminal, Bar, gate cluster = 3. One to three more sites are unspecified.
**Candidates from existing documentation:**
- Commuter transit hub (if Option B from T-03 — separate location with its own social dynamics)
- Sector 3 (Miri — named area with ventilation issue; has a location but no defined social site yet)
- Maintenance junction node (a secondary gathering point in the maintenance spine? Non-obvious)
- A commissary, clinic, or administrative sub-office (generic service space with resident NPCs)
**What Round 2 needs:** Names and rough positions for the remaining social sites, or a decision to scope to 3 (Terminal + Bar + gate cluster) and justify why that satisfies Gestalt's minimum. Note: 3 may be sufficient if the gate cluster is large enough to function as 1.5 sites.
**Generator implication [template]:** Social site count is a generator parameter (minimum: 4). The template needs named social site types and their relationship requirements (which types must be adjacent, which must be separated).
---
#### T-05 — Transition Corridor Crossing Time
**What's at stake:** Ozzie requires 20–25 seconds at Walk stance for the corridor crossing. At Walk (1 tile / 2 ticks, 10 tps = 5 tiles/sec), 20–25 seconds = 100–125 visual tiles. The transition corridor in the smuggling layout is ~40m = 40 visual tiles, achieving only ~8 seconds.
**Options:**
- **Option A — Extend the corridor.** Make the physical transition corridor 100–125 tiles. This is very long (100–125m) and may not fit the station profile ("compact district, 800 population").
- **Option B — Accept 8 seconds.** Adjust Ozzie's target to match the physical reality. 8 seconds at Walk is not nothing — it's still a beat. Ozzie's 20–25s may be aspirational rather than hard.
- **Option C — Add intermediate spaces.** The transition route includes the forecourt (Terminal side) and entry zone (Bar side). If total route = corridor + forecourt + bar entry area, total walking distance may reach 60–80 tiles (~12–16 seconds). Closer to the target without an implausibly long corridor.
**Generator implication [template]:** Inter-site transit time is a gameplay parameter. The template should specify minimum/maximum transit time between major social sites, not corridor length directly.
---
#### T-06 — Maintenance Spine Route (Era 1 vs. Efficient Path)
**What's at stake:** Miri says the maintenance spine predates the buildings (Era 1 construction). If the spine's route is fixed and buildings were placed around it, the maintenance corridor between Terminal and Bar may not run in the most direct path. But the smuggling layout shows a relatively direct corridor connection. Is the route direct (efficient) or wandering (Era 1 authentic)?
**The tension:** A wandering Era 1 corridor is setting-authentic but adds tile complexity and may not fit cleanly in the district layout. A direct corridor is simpler to lay out but slightly undermines Miri's historical rationale.
**Generator implication [template]:** Maintenance spine routing is a generator parameter. The template should specify whether maintenance corridors follow the shortest path or use a historically-layered routing algorithm.
---
#### T-07 — Corridor Width Minimums (Unspecified)
**What's at stake:** Araminta flagged minimum corridor widths by type but did not provide values. Confirmed: maintenance corridor = 2m (2 visual tiles), transition corridor = 6m (6 visual tiles). Unspecified: internal building corridors, secondary public corridors, service alcoves.
**Round 2 needs:** Explicit minimum widths for each corridor type. These become V-05's specified values and feed into the generator's spatial layout rules.
---
#### T-08 — Gate Cluster Zone Temperature Tint
**What's at stake:** D-059 assigns zone temperature tints to Terminal (cool dark), Bar (warm dark), corridors (neutral dark). The gate cluster is a fourth zone type requiring a tint. Araminta says it's the "coolest and newest" — suggesting a colder tint than the Terminal. But D-059 already uses "cool dark" for the Terminal. The gate cluster needs a distinct value.
**Options:** Very cool (near-white institutional), clinical blue-white, or a Commission-grey that reads as "newer" than Terminal's grey-navy.
**Generator implication [template]:** Zone temperature tint is a per-zone-type parameter. The template needs a tint for each of: logistics-hub, social-venue, transit-corridor, entry-gate. Currently only the first three are decided.
---
## §2 — ROUND 2: Cross-Examination and Lead Feedback
*Compiled by Qatux. Sources: 6 agent Round 2 contributions + lead feedback that resolved T-01 and T-05.*
---
### Lead Feedback (resolved before agents responded)
**Chunk size direction:** Lead prefers larger chunks with a sub-chunk quarter system. Chunks divide into 4 quarters that can merge into one edifice or remain separate. Large civic structures (train stations, government buildings) span multiple chunks. Generator must be top-down: geography → infrastructure → amenities → population → zoning → chunk generation → individual fill. Separate generator architecture workshop required — this is architectural precedent, not v0.1 configuration.
**Z-level PoC:** Lead mandates one building in the district with a staircase as z-level proof-of-concept. Gate cluster observation gallery selected by consensus — the only unconfirmed location, setting-authentic, investigation-valuable, clean implementation test case. Overrides V-07's "no z-levels without justification" — the PoC IS the justification.
**T-01 resolved — Option C:** "Hearing through walls is a flimsy core proposition. We don't build out of cardboard." Sound-through-walls is an exception, not a pattern. Detection toolkit is cameras, drones, bugs, maintenance shafts/vents/tunnels. Path B starts on the cargo floor, not the break room. Terminal layout #311 unchanged.
**T-05 resolved — Careful stance:** Confirmed. Full route at Careful (3.33 tiles/sec per D-053) ≈ 80 tiles = ~24 seconds. Constraint is stance-dependent, not corridor-length-dependent. No layout change needed.
**Gate/transport lore clarification (S-02 revision):** System gates serve only freight externally. "Commuter transit" = internal station transit (train/tram) from Residential Core. One external entry (span gate). One internal transit stop within the district. T-03 reframed as T-03b: where does the internal transit stop sit?
**Transport lore questions:** Captured as Q-040–Q-044. All assigned to Miri.
---
### Agent Round 2 Positions
**Ozzie (Player Experience)**
- Confirmed T-05 can be satisfied by Careful stance measurement — accepts this resolution
- "Strong yes" on G-11 (gate cluster observation gallery as player investigation vantage point)
- Flagged V-06 exception: Terminal main corridor should be bare of LOS anchors — the exposure is the gameplay mechanic, not a design oversight. Open spaces that are *meant* to feel dangerous are exempt from the anchor rule
- Requested G-08 receive an official name in the D-record (the "nothing looks wrong" principle — mundane face on criminal infrastructure)
**Gestalt (Systems Design)**
- Gate cluster triangle composition: customs officer + freight forwarder + waiting commuter (3 NPCs, distinct interests, credible spatial conflict)
- 4th social site = **transit hub** (bar-side internal transit stop has its own NPC population, social dynamics, and convergence function). This resolves T-04 — site count: Terminal + Bar + Gate cluster + Transit hub = 4, satisfying G-02 minimum
- G-11 confirmed: observation gallery at z=2 is a valid **investigation vantage point** — qualifies as a gameplay-justified z-level (H-13 / V-07 condition satisfied by lead's PoC directive)
**Miri (Worldbuilding)**
- [PENDING Round 3 — horizon station revision, T-04 position, Commission overlap resolution]
- Internal transit stop: bar-side position accepted (workers commute to bar district, then walk to Terminal for shift)
- Transport lore model submitted — see Q-040–Q-044 for captured questions
**Tyre (Technical)**
- [PENDING Round 3 — chunk size technical confirmation at 64×64 visual, z-level validation, cross-z shadowcasting spec]
**Araminta (Visual/Spatial)**
- **Corridor width minimums (V-05 now specified):**
| Corridor type | Width (visual tiles) | Width (meters) |
|--------------|---------------------|----------------|
| Maintenance corridor | 2 | 2m |
| Internal building corridors | 2 | 2m |
| Secondary public corridors | 4 | 4m |
| Transition corridor | 6 | 6m |
| Gate customs lanes | 2 | 2m (per lane) |
| Gate concourse | 8 | 8m |
| Service alcoves | 1 | 1m |
- **Gate cluster zone temperature tint:** `#0a1520` — deep institutional cold, distinct from Terminal's cool-grey-navy. Coldest zone in the district
- **Gate observation gallery spec:** 4 tiles wide × lane-length, Commission grey-white palette. Gallery floor is z=2; same zone tint as gate cluster ground floor (elevation ≠ new zone)
- **Outlier on chunk size:** Araminta prefers 64×64 sim (32×32 visual). Rationale: visual tile is the authoring unit; generator should reason at visual-tile scale. Noted as minority position
**Paula (Narrative)**
- **Gate cluster triangle revised:** operations manager + senior freight handler + Commission inspector (3 NPCs). Rationale: Commission inspector is Sera Venn's institutional peer — this creates the Commission overlap (N-01) without requiring Sera to be permanently stationed at Terminal. Inspector visits = legitimate, scheduled, observable
- **T-01 revised:** Path B via restricted storage door, not break room wall. The anomalous sound is footsteps on the maintenance grating, heard through the (imperfect) seal around the restricted storage access door on the cargo floor — not sound through a solid wall. The door is the weak point, not the wall. This preserves Option C without invoking cardboard-wall physics
- **Path C confirmed:** A third investigation approach for the detective — the Commission inspection overlap. The Commission inspector (gate cluster triangle NPC) has access to the same manifest anomalies as Maret but reads them as institutional compliance failures, not criminal ones. Detective PC who befriends the inspector gets a different angle on the evidence: institutional rather than human
- **Sector 3:** Supports Miri's framing — sub-area adjacent to maintenance spine, ventilation complaint is ambient NPC dialogue, not a full social site
- **6 narrative constraints for D-record:** Commission inspector has access to gate cluster AND Terminal (inspection authority crosses building boundaries); inspector's Terminal visits are scheduled (visible, predictable — ring can route around them); Sera is a bar regular who knows the inspector professionally (Triangle 4 cross-link); no NPC in the gate cluster triangle has social connection to Voss (Terminal triangle separation maintained); transit hub NPCs are socially isolated from the Terminal workers (two distinct working cultures); back room alley exit connects to maintenance alley which connects to district edge, NOT to the transit hub service area
---
### Round 2 Tension Status
| Tension | Status after Round 2 | Resolution |
|---------|---------------------|------------|
| T-01 Break room adjacency | **RESOLVED** | Option C: cargo floor + restricted storage door acoustic gap |
| T-02 Gate cluster scope | **RESOLVED** | Two competing triangle compositions (Gestalt vs. Paula) — Round 3 to pick one |
| T-03b Transit stop placement | **RESOLVED** | Bar-side — consensus |
| T-04 4th social site | **RESOLVED** | Transit hub (bar-side) |
| T-05 Corridor crossing time | **RESOLVED** | Careful stance ~24s on ~80-tile route |
| T-06 Maintenance spine routing | **RESOLVED** | Direct path for v0.1; wandering routing deferred to generator workshop |
| T-07 Corridor widths | **RESOLVED** | Araminta's table (see above) |
| T-08 Gate cluster tint | **RESOLVED** | `#0a1520` deep institutional cold |
| NC-01 Transit stop placement | **RESOLVED** | Same as T-03b |
| NC-02 G-10 chunk alignment | **PARTIALLY RESOLVED** | At 64×64 visual chunks, chunk ≈ zone; G-10 revised accordingly |
| NC-03 Gallery tint (z=2) | **RESOLVED** | Same tint as gate cluster ground floor |
| NC-04 Sector 3 / maintenance spine | **RESOLVED** | Adjacent; ventilation = ambient NPC dialogue |
**Remaining for Round 3:** Gate cluster triangle — pick Gestalt's composition or Paula's. Chunk size — confirm 64×64 visual (Tyre analysis pending). Miri's horizon station revision and Commission overlap resolution.
---
## §3 — ROUND 3: Convergence
*Compiled by Qatux. All 6 agents delivered Round 3. All tensions resolved. D-records filed: D-093, D-094, D-095.*
---
### Confirmed Consensus
**T-01 (break room / Path B):** Option C confirmed by lead. Sound anomaly at restricted storage door (cargo floor) → Path B. Paula's refinement accepted: acoustic gap is the door seal, not a wall. No modification to terminal layout #311.
**T-03b (transit stop):** Bar-side. Unanimous. Creates NPC convergence point at bar entry (satisfies G-03).
**T-04 (4th social site):** Sector 3 residential (Drin/Naia anchor). Miri's Round 3 revision supersedes the interim Transit Hub consensus. The transit platform (The Loop stop) is reclassified as an encounter node (bar-side convergence point). Site count = 4 (Terminal, Bar, Gate cluster, Sector 3 residential). G-02 minimum satisfied.
**T-05 (crossing time):** Walk ~13–14s on ~65–70 tile route; Careful stance ~24s. Ozzie confirms. No layout change.
**T-06 (maintenance spine routing):** Direct path for v0.1. Generator-level concern deferred.
**T-07 (corridor widths):** Araminta's table confirmed. Filed as V-05 specified values.
**T-08 (gate cluster tint):** `#0a1222` deep institutional cold (Araminta final). Gallery at z=2 shares ground-floor tint.
**G-11 (observation gallery as investigation vantage):** Confirmed by Gestalt + Ozzie. The gate cluster observation gallery at z=2 is a designated investigation position — the player can observe arriving cargo from elevation. This is the gameplay justification for the z-level PoC.
**G-08 naming:** Confirmed name: **"Invisible infrastructure principle"** — every ring location serves a mundane purpose; criminal function is only apparent if you know what to look for. This is the spatial design principle stated in the smuggling layout and now formally named.
**V-06 exception (main corridor):** The Terminal main corridor is explicitly exempt from the LOS anchor rule. The bare, unobstructed corridor is the gameplay mechanic — exposure IS the design. Araminta confirmed.
**Gate cluster triangle:** Paula's composition selected — operations manager + senior freight handler + Commission inspector. Rationale: Commission inspector creates the narrative bridge (N-01, Path C, Sera's institutional peer) that Gestalt's commuter-based composition cannot provide.
**Path C (Commission inspection overlap):** Confirmed. The Commission inspector at the gate cluster has institutional access to Terminal manifest data. Detective PC who builds relationship with inspector gains a third, institutional angle on the evidence. Non-confrontational. Sera Venn cannot access gate cluster customs records without a formal Commission request — separate institutional chains.
**Sector 3 residential (S-04):** Sub-area adjacent to maintenance spine. 4th confirmed social site (D-025). Anchor NPCs: Drin and Naia (ongoing ventilation dispute). Industrial Sector jurisdiction. Ambient dialogue provides local texture without plot relevance.
**Commission overlap resolution (N-01):** Gate cluster customs zone is Commission-jurisdictioned space. Commission inspector has scheduled inspection authority crossing into Terminal. This explains Sera's legitimate Terminal presence without requiring her to be stationed there.
**Chunk size (confirmed):** Chunk = 64×64 sim (32×32 visual, 32m) — streaming unit. Block = 128×128 sim (64×64 visual, 64m) — generator planning unit, 4 chunks. District = 4×4 blocks = 512×512 sim (256×256 visual, 256m). Quarter = 32×32 visual within a block (sub-block unit for generator fill). Araminta dissent (preferred 32×32 visual chunk) noted, overruled. Amends D-012; supersedes D-014 estimate. Filed as D-094.
**Z-level scheme (confirmed):** z=0 maintenance corridor (Era 1), z=1 all main district structures (Terminal, Bar, Gate cluster ground, transit platform), z=2 Gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall; player on z=2 sees z=1 below; z=1 cannot see upward unless at staircase. Confirmed by Tyre.
**Transport lore (Miri — confirmed):** Span gates: human-built, single aperture, dual-use windows (freight/passenger). Horizon stations: alien-built, 4–8 apertures, Oort-cloud distance, "The Ring" per-system. Sequential hop travel only. The Loop: 6-district internal tram, 4min Residential Core → Transit District. Station profile correction: Sova's horizon gates at The Ring, not Admin Hub. Q-040/Q-041/Q-043/Q-044 resolved → D-093/D-095. Q-042 partially resolved. Filed as D-095.
---
### Round 3 — All Items Resolved
All tensions and open items from Rounds 1 and 2 resolved. D-records filed: D-093 (`decisions/content.md`), D-094 (`decisions/architecture.md`), D-095 (`decisions/content.md`). No pending items.
---
## §4 — D-RECORDS FILED
*Filed by Qatux, 2026-02-25. D-093 (Sova Transit District spatial layout), D-094 (District spatial hierarchy), D-095 (Horizon stations and gate infrastructure). See `decisions/content.md` and `decisions/architecture.md`.*
---
### D-093: Sova Transit District — Spatial Layout and District Topology
**Decision file:** `decisions/content.md`
**Date:** 2026-02-25
**Source:** Station District Layout Workshop, Ticket #153 (Sprint 20)
**Raised by:** Full team (Gestalt, Miri, Araminta, Tyre, Paula, Ozzie). Compiled by Qatux.
**Dissent:** Araminta on chunk size (prefers 32×32 visual chunk; overruled by lead and team majority). No other dissent.
---
#### Decision
The Sova Transit District spatial layout is confirmed as follows.
---
#### 1. District Topology
```
N (external — span gate, horizon station connection)
↑
┌─────────────────────────────────────────┐
│ GATE CLUSTER │ ~40×32 visual tiles [T-02 est.]
│ z=1: gate floor, customs lanes (2m ea),│ Tint: #0a1222 (institutional cold)
│ concourse (8m), processing zone │ Access: PUBLIC (concourse)
│ z=2: observation gallery (4 tiles wide)│ SEMI-PRIVATE (customs lanes)
│ Commission grey-white palette │ PRIVATE (inspection booths)
└────────────────┬────────────────────────┘
│ forecourt (~8–10 tiles deep)
┌────────────────┴────────────────────────┐
│ TERMINAL (Sova Logistics Hub) │ 44×28 visual tiles (confirmed #311)
│ z=1. Tint: cool grey-navy │ Access: PUBLIC (entry lobby)
│ Entry lobby → scanner bays → │ SEMI-PUBLIC (main corridor)
│ main corridor → cargo floor / │ SEMI-PRIVATE (cargo floor,
│ manifest processing / break room → │ manifest proc., break room)
│ restricted storage (coded, 1 door) │ PRIVATE (supervisor office,
│ Supervisor office: [=] window south │ restricted storage)
└────────────────┬────────────────────────┘
│ ← maintenance corridor z=0 branches east here
│ (restricted storage → M-HATCH-T → 40m → M-HATCH-B)
│
TRANSITION CORRIDOR
~40m × 6m (6 visual tiles wide) Tint: neutral dark
Surveillance camera at T=24m Access: SEMI-PUBLIC
Widening zones at both ends
(forecount N-side, bar entry S-side)
│
│ ← maintenance corridor z=0 terminates at M-HATCH-B
│ (opens into bar bathroom corridor)
┌────────────────┴────────────────────────┐
│ TRANSIT PLATFORM (encounter node) │ [dimensions TBD — ~12×8 visual est.]
│ z=1. The Loop tram stop (bar-side) │ Tint: neutral-warm (transitional)
│ (tram from Residential Core) │ Access: PUBLIC
│ NPC population: commuters, workers. │
│ Convergence point #2 (G-03). │
└────────────────┬────────────────────────┘
│ (bar entry zone, ~5 tiles)
┌────────────────┴────────────────────────┐
│ BAR — THE LAST SHIFT │ 28×22 + 6m east extension visual (confirmed #312)
│ z=1. Tint: warm dark amber │ Access: PUBLIC (main floor, card table)
│ East extension (bathroom corridor) │ SEMI-PRIVATE (serving south,
│ faces north toward transit hub. │ bathroom corridor)
│ Alley door (south) → maintenance │ PRIVATE (back room)
│ alley → district edge. │
└─────────────────────────────────────────┘
↓
S (maintenance alley — district edge)
─────────────────────────────────────────────────────────────────────
MAINTENANCE CORRIDOR (z=0, runs parallel to transition corridor)
[RESTRICTED STORAGE north wall / cargo floor door]
→ M-HATCH-T (locked hatch, restricted storage interior)
→ EAST SERVICE SPINE (~15m, narrow, dim)
→ JUNCTION-1 (DD-2 dead-drop location)
→ [side branch west] SECTOR 3 RESIDENTIAL (~15×12 visual)
Drin/Naia (ventilation dispute anchor)
4th social site (D-025). Access: PUBLIC
→ DISTRICT SPINE (~25m, grating floor)
→ DD-3 "The Mark" (go/no-go signal, mid-corridor)
→ M-HATCH-B (locked hatch, bar bathroom corridor)
Era 1 construction. No Meridian coverage. Grating floors.
Zero LOS from all other spaces. Zero regular NPC traffic.
Access: PRIVATE (ring members only in practice).
```
---
#### 2. Zone Dimensions
| Zone | Visual tiles | Sim tiles | Notes |
|------|-------------|-----------|-------|
| Gate cluster (z=1) | 40×32 | 80×64 | Araminta confirmed — 7 zones (see §4.2 zone spec) |
| Gate observation gallery (z=2) | 32×10 | 64×20 | Commission grey-white; Araminta confirmed |
| Terminal | 44×28 | 88×56 | Confirmed (#311) |
| Forecourt (Terminal N-face) | ~44×10 | ~88×20 | Estimate |
| Transition corridor | ~40×6 | ~80×12 | Confirmed (#313) |
| Transit platform | ~12×8 | ~24×16 | Encounter node — The Loop stop; bar-side |
| Sector 3 residential | ~15×12 | ~30×24 | 4th social site (D-025); maintenance spine adjacent |
| Bar entry zone | ~28×5 | ~56×10 | Estimate |
| Bar (Last Shift) | 28×22 (+6m E ext.) | 56×44 (+12 E ext.) | Confirmed (#312) |
| Maintenance corridor | ~40×2 | ~80×4 | Confirmed (#313) |
| **Total district bounding box** | **~256×256 visual** | **~512×512 sim** | 4×4 blocks per D-094; D-014 estimate superseded |
---
#### 3. Access Topology
Sequential. No tier skipping (H-06).
```
PUBLIC → SEMI-PUBLIC → SEMI-PRIVATE → PRIVATE
────── ─────────── ──────────── ───────
Gate concourse Transition corridor Terminal cargo floor Supervisor office
Gate floor Terminal main corridor Terminal manifest proc. Restricted storage
Terminal lobby Gate customs entry Terminal break room Maintenance corridor
Transit hub Bar serving south Bar back room
Bar main floor Bar bathroom corridor
Bar card table
Bar counter (cust. side)
```
Movement across tiers requires: worker role (trivial), authority (badge → semi-private), access code (private), ring membership (maintenance corridor/restricted storage).
---
#### 4. Spatial Hierarchy (Confirmed Naming)
- **Chunk** = 64×64 sim (32×32 visual, 32m) — streaming and serialization unit
- **Block** = 128×128 sim (64×64 visual, 64m) — generator planning unit; composed of 2×2 chunks (4 chunks per block)
- **District** = 4×4 blocks = 512×512 sim (256×256 visual, 256m); 16 blocks, 64 chunks per z-level
- **Chunk merge rules:** Adjacent chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes, shacks), or form L-shaped buildings across chunk boundaries
- **Large civic structures:** Span multiple blocks (gate cluster, horizon station installations, stadiums, parks)
- **District skeleton:** Each major social site occupies approximately one chunk (32×32 visual) within its block
**Generator architecture note:** The full top-down generator model (geography → infrastructure → amenities → population → zoning → block generation → chunk fill) requires a dedicated workshop brief. This chunk/block/district specification is the spatial primitive for that future system. Q-036 tracks district skeleton design.
---
#### 5. Z-Level Scheme
| Level | Contents | Notes |
|-------|----------|-------|
| z=0 | Maintenance corridor (Era 1) | Full district. Dim cold-white lighting. Grating floors. No Meridian. |
| z=1 | All main district structures | Terminal, Bar, Gate cluster ground, transit platform, Transition corridor |
| z=2 | Gate cluster observation gallery only | 4 tiles wide × lane-length. Commission grey-white palette. Access via staircase in gate cluster. |
**Cross-z LOS (confirmed, Tyre):** Vertical LOS propagates only through designated transparent floor/window tiles. Opaque floor tile = full LOS block. Gallery rail = transparent floor tile, designer-placed. Player on z=2 has LOS downward through transparent rail to gate floor (z=1); LOS does not propagate upward from z=1 except at staircase opening. Performance cost: ~100–150µs per additional FOV pass — trivial. Gallery observation capability is architecturally controlled by designer tile placement, not a special case.
**Inter-z sound (confirmed, Tyre):** Sound propagates across z-levels only through open hatches and designated vent tiles. The maintenance corridor (z=0) is inaudible from z=1 except at hatch locations (M-HATCH-T inside restricted storage, M-HATCH-B in bar bathroom corridor). This makes the acoustic gap at the cargo floor door (Path B) mechanically coherent — it is a z=1 surface feature, not a z=0 leak.
**Memory (confirmed, Tyre):** Three z-levels for this district = ~1.35MB. Trivial.
**Z-level PoC purpose:** The gate cluster observation gallery proves the z-level rendering stack (D-049) and cross-z shadowcasting (D-035) for all future development. This is the only z=2 space in v0.1.
---
#### 6. Key Sightline Relationships
*Across the full district:*
| From | To | LOS | Notes |
|------|----|-----|-------|
| Gate observation gallery (z=2) | Gate floor below (z=1) | ✓ downward | Player sees cargo off-loading from above |
| Transition corridor (any position) | Terminal exterior / Bar exterior | ✗ | Buildings are opaque from outside |
| Transit hub | Bar entry zone | ✓ | Convergence point — workers arriving see bar entrance |
| Maintenance corridor | Anywhere | ✗ | Zero LOS in or out. Sound only. |
| Maintenance hatch (M-HATCH-T) | Cargo floor | ✗ | Hatch opens inside restricted storage — only visible to someone already in restricted storage |
*Within Terminal (from #311 — unchanged):*
- Main corridor → all four south-facing doors: ✓
- Supervisor window [=] → cargo floor + restricted storage door: ✓
- Break room → anything: ✗ (isolated)
- Manifest processing → main corridor (door open): ✓
*Within Bar (from #312 — unchanged):*
- Corner booth (NW deepest) → entrance, bar counter, card table, back room door: ✓
- Bathroom corridor interior → main bar: ✗ (door only)
---
#### 7. NPC Routes and Convergence Points
Two confirmed convergence points outside Terminal and Bar (satisfies G-03):
1. **Terminal main corridor** — all Terminal workers cross here. Every person moving between scanner bays (north) and cargo floor / manifest processing / supervisor office (south) passes through. Semi-public; lingering is suspicious.
2. **Transit platform / bar entry zone** — workers arriving from Residential Core via The Loop tram. Some walk north to Terminal, some enter bar directly. NPCs from different social sites share this arrival space. Encounter node, not social site.
Additional convergence: **Transition corridor** — ring members and workers both use this corridor. The surveillance camera at T=24m records all transits. Pattern analysis reveals ring operational schedule.
---
#### 8. Investigation Paths (confirmed)
**Path A — Pattern Recognition (insert-heavy):**
Corridor camera logs → Kael's deep-night transit pattern → manifest database access (manifest processing) → restricted storage access timing → ring identified.
**Path B — Physical Traversal (exploration-heavy):**
Cargo floor: anomalous sound at restricted storage door (acoustic gap around coded door seal) → floor worker conversation → physical discovery of maintenance hatch → corridor traversal → dead-drops discovered. *Note: sound propagation through door seal, not wall. Door is the weak point.*
**Path C — Commission Inspection Overlap (institutional):**
Detective builds relationship with Commission inspector (gate cluster triangle NPC) → inspector shares institutional read of manifest anomalies (compliance framing, not criminal) → detective gains third angle on same evidence. Non-confrontational. Complements Paths A and B.
---
#### 9. Transport Lore
*Captured from workshop discussion:*
**Span gates (Q-040 — resolved → D-093):** Human-built. Single aperture. Near-instantaneous transit. Scheduled dual-use windows: freight (bulk of operating hours) and passenger (scheduled slots). Physical layout reflected in gate cluster zone spec (§4.2).
**Horizon stations (Q-041 — resolved → D-095):** Alien-built (no identified builder species). Self-maintaining. 4–8 apertures per station. Located at Oort-cloud distance. Per-system canonical name: "The Ring." Sequential hop travel only (A→B→C through intermediate systems; no direct long-range transit). Per-system variation across 4 access tiers. Station Sova's horizon gates are at The Ring — Admin Hub contains booking offices only.
**Intra-system transport (Q-042 — partially resolved):** Span gates at star/planetary level plus horizon stations at Oort distance. Details of intra-system hab-to-hab transit remain open.
**Station internal transit (Q-043 — resolved → D-095):** "The Loop" — internal station tram, 6 districts, 4-minute Residential Core → Transit District run. Workers arrive at transit platform (bar-side) and disperse to Terminal or bar.
**Gate-train integration (Q-044 — resolved → D-093/D-095):** Arriving passengers exit span gate → aperture chamber → freight/passenger customs lanes → gate concourse (public) → transition corridor → transit platform (The Loop). No direct gate-to-tram connection; transition corridor is the linking space.
---
#### 10. Gate Cluster Social Triangle
**Composition:** Operations manager + senior freight handler + Commission inspector
**Spatial staging:**
- Operations manager: works the gate floor (supervises cargo processing, knows every shipment)
- Senior freight handler: works customs lanes (clears cargo for transit; knows what should and shouldn't be there)
- Commission inspector: scheduled inspection visits (legitimate authority, reads anomalies institutionally)
**Triangle tension:** Operations manager and freight handler have an established working relationship — and a shared interest in not attracting Commission attention. Commission inspector is an outside force disrupting their equilibrium. Investigation opportunity: the inspector sees things the manager and handler want invisible.
**Commission overlap (N-01, Path C):** Inspector has institutional access to Terminal manifest data. Sera Venn's professional peer. Detective who builds relationship with inspector gains Path C.
---
#### 11. Invisible Infrastructure Principle (G-08, now named)
Every ring location serves a mundane purpose. Criminal function is only apparent if you know what to look for. The player (smuggler PC) knows. The detective PC can learn. An NPC observing in isolation sees nothing unusual. Same physical space — different player understanding.
This is the core spatial design principle for the district. All investigation paths are designed around it. The principle is generalisable: it applies to any district in the Reach that hosts a Tier 1 module.
---
#### 12. Narrative Constraints (Paula — all confirmed)
1. Commission inspector has inspection authority crossing gate cluster AND Terminal — schedules are publicly known
2. Inspector's Terminal visits are scheduled and predictable — ring can route around them; gives ring temporal structure
3. Sera Venn is a bar regular who knows the inspector professionally — Triangle 4 cross-link
4. No NPC in the gate cluster triangle has social connection to Voss — Terminal and gate cluster triangle separation maintained
5. Transit hub NPCs are socially isolated from Terminal workers — two distinct working cultures; they share the transition corridor but not social space
6. Back room alley exit connects to maintenance alley → district edge; does NOT connect to transit hub service area
---
#### Rationale
The district layout emerged from three rounds of cross-domain synthesis:
- Round 1 established 52 constraints across 6 domains and 8 open tensions
- Round 2 resolved 10 of 11 tensions; lead feedback resolved T-01 (cargo floor) and T-05 (Careful stance)
- Round 3 achieved consensus on all remaining items
The layout satisfies: D-025 (social sites as atomic template units), D-036 (Sova Transit District setting), D-054 (tile-based movement), D-059 (fog zone temperature tints), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice success criteria).
The chunk/quarter system and top-down generator model establish architectural precedent for Q-036 (district skeleton as generator output).
---
#### Cross-References
- Terminal layout: `docs/design/spatial-layout-terminal-v01.md` (#311)
- Bar layout: `docs/design/spatial-layout-bar-v01.md` (#312)
- Smuggling corridors: `docs/design/spatial-layout-smuggling-corridors-v01.md` (#313)
- Station profile: `docs/design/sova-station-profile.md` (#320)
- Gate cluster layout: `docs/design/spatial-layout-gate-v01.md` (to be authored — blocks #157)
- Transit hub layout: TBD (new ticket required)
- Transport lore open questions: Q-040–Q-044
- Generator architecture workshop: pending brief
---
*D-records filed: D-093 (decisions/content.md), D-094 (decisions/architecture.md), D-095 (decisions/content.md). Gate cluster full layout to be authored as #157. District bounding box (256×256 visual) supersedes D-014 estimate per D-094.*
+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
```
+115
View File
@@ -0,0 +1,115 @@
# Sprint 20: Shape — Client Tasks
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 19
None — Sprint 19 complete. #554 (save/load client UI) is finishing in Sprint 19.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #557 | Refactor: game_state.gd derived state in apply_snapshot() | — |
| #558 | Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling | — |
| #559 | Refactor: main.gd god coordinator — extract SnapshotEventRouter | — |
| #560 | Refactor: unify duplicate YAML parsers | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot is a pure renderer: no game logic in GDScript; GameState reflects server-authoritative data, not derived behavior), D-085 (per-game save directory structure: `user://saves/<timestamp>-<seed>/`, F5=quicksave, F6=quickload, loading screen lists dirs by last-modified), D-088 (3-state pause system: client sends pause requests, server is authoritative)
- `decisions/scope.md` — D-027 (vertical slice success criteria: game session must be resumable for 30-min playthroughs)
## Notes
### #557 — Refactor: game_state.gd derived state in apply_snapshot()
Code review finding: `apply_snapshot()` in `client/scripts/autoloads/game_state.gd` computes two derived values inline:
- `stationary_ticks` (increments when player position hasn't changed) — line 99 / ~line 131-133
- `current_zone_id` (derived from tile iteration) — line 105 / ~line 286
Per D-020, the Godot client is a pure renderer. Behavior-driving computations (stationary tick counting, zone identification) belong in the server, not in `apply_snapshot()`. The server already sends `zone_id` per tile — the client should read it directly rather than re-deriving it.
What this ticket must deliver:
- Move `stationary_ticks` accumulation out of `apply_snapshot()`. The server sends `stationary_ticks` (or equivalent) in the snapshot — if not yet present, add the field to `ObserverSnapshot` in `client/scripts/protocol/protocol.gd` and mark with a TODO for the server team to populate it. Client reads the server value directly.
- Move `current_zone_id` resolution to a simple property read from the snapshot (`player_tile.zone_id`), removing the tile iteration loop from `apply_snapshot()`.
- After: `apply_snapshot()` contains only direct field assignments from the snapshot dictionary — no conditional logic, no accumulation.
- Add a comment citing D-020 on each removed computation to document the rationale.
- Unit tests: `apply_snapshot()` with a snapshot missing the new fields should degrade gracefully (default values, no crash).
Integration points: `client/scripts/autoloads/game_state.gd` only. Protocol fields may need a minor extension in `client/scripts/protocol/protocol.gd` — coordinate with server team if new snapshot fields are required.
Gotcha: `stationary_ticks` drives `ListeningFocus` (D-071) — confirm the server already tracks and sends this value before removing client-side accumulation. If the server does not yet send it, add a feature-flagged fallback that keeps the old behavior with a deprecation comment.
### #558 — Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling
Code review finding: `client/ui/dialogue_box.gd` directly mutates `GameState.dialogue_active` at 3 call sites (lines ~289, ~321, ~334) and calls `AudioManager.apply_dip()` / `AudioManager.clear_dip()` directly.
Per D-020, UI components should not mutate shared state or call sibling autoloads directly — they should emit signals and let a coordinator (main.gd or a future SnapshotEventRouter) manage cross-component state.
What this ticket must deliver:
- Replace the 3 `GameState.dialogue_active = true/false` assignments with a signal: `signal dialogue_state_changed(active: bool)`. `main.gd` connects to this signal and updates `GameState.dialogue_active`.
- Replace `AudioManager.apply_dip("dialogue")` and `AudioManager.apply_dip("confrontation")` / `AudioManager.clear_dip()` calls with signals: `signal audio_dip_requested(profile: String)` and `signal audio_dip_cleared()`. `main.gd` connects to these and calls `AudioManager`.
- Result: `dialogue_box.gd` has zero references to `GameState` or `AudioManager`.
- Unit tests: mock signal receivers capture the emitted signals with correct arguments; no direct autoload calls remain.
Integration points: `client/ui/dialogue_box.gd` (source), `client/scripts/main.gd` (connects to new signals in `_ready()`). No server changes.
Gotcha: `InputMapper` checks `GameState.dialogue_active` to suppress movement. The signal path adds one frame of latency — verify that the signal fires synchronously within the same frame (use `call_immediate` or connect with `CONNECT_DEFERRED` depending on timing requirements). The `is_dialogue_active()` method on `dialogue_box.gd` (line 337) can remain as a local query without touching `GameState`.
### #559 — Refactor: main.gd god coordinator — extract SnapshotEventRouter
Code review finding: `client/scripts/main.gd` is 517 lines and dispatches to 15+ child nodes through a set of `consume_*` methods that all follow the same pattern: read field from snapshot, call method on child node.
What this ticket must deliver:
- Extract a `SnapshotEventRouter` class (`client/scripts/snapshot_event_router.gd`): takes the snapshot dictionary and routes each field to the correct child node via a registered handler map.
- Registration pattern: `router.register("monologue", monologue_display.consume_monologue)` — callable-based dispatch. Handlers are registered in `main.gd`'s `_ready()`.
- `main.gd` `_process()` calls `router.dispatch(snapshot)` instead of 15+ individual `if snapshot.has("X"): child.consume_X()` blocks.
- `main.gd` retains scene tree ownership (`@onready` node references), camera logic, and input handling — the router only handles snapshot dispatch.
- After: `main.gd` should be under 350 lines.
- Unit tests: construct a `SnapshotEventRouter` with mock handlers, dispatch a snapshot, assert each handler received the correct field value.
Integration points: `client/scripts/main.gd` (refactor target), new file `client/scripts/snapshot_event_router.gd`. No server changes, no protocol changes.
Gotcha: Some consume methods in `main.gd` have cross-field dependencies (e.g., camera position depends on both `player_position` and `_camera_anchored` state). Identify these upfront and keep them in `main.gd` directly — only pure per-field dispatch moves to the router. Do not force all logic into the router pattern.
### #560 — Refactor: unify duplicate YAML parsers
Code review finding: `client/scripts/checklist/checklist_evaluator.gd` contains its own YAML parser that partially duplicates `client/scripts/autoloads/ui_strings.gd`'s `_parse_yaml()` method.
What this ticket must deliver:
- Extract a shared `YamlParser` utility class at `client/scripts/util/yaml_parser.gd` (create the `util/` directory).
- `YamlParser` exposes a static method `parse(text: String) -> Dictionary` that handles the common subset of YAML used across both call sites (key: value pairs, nested maps, arrays).
- Replace `checklist_evaluator.gd`'s inline parser with `YamlParser.parse()`.
- Replace `ui_strings.gd`'s `_parse_yaml()` with `YamlParser.parse()` (or delegate to it, keeping the method signature stable).
- Unit tests: parse a sample YAML string with nested keys, arrays, and string values; assert round-trip correctness.
Integration points: `client/scripts/checklist/checklist_evaluator.gd`, `client/scripts/autoloads/ui_strings.gd`, new `client/scripts/util/yaml_parser.gd`. No server changes.
Gotcha: The two existing parsers may handle edge cases differently. Write the unit tests first against both parsers to document their current behavior, then unify. Prioritize correctness for existing content files (`client/data/ui-strings.yaml` and any checklist YAML files) — do not break live content.
## Dependency Chain
```
#557 (game_state derived state) ─┐
#558 (dialogue_box coupling) ├─ all parallel, no inter-dependency
#559 (main.gd SnapshotEventRouter)│ #558 feeds into #559 (signal wiring in main.gd)
#560 (unify YAML parsers) ─┘
```
#558 should complete before #559 so that the new signals from dialogue_box are wired into `main.gd` as part of the router work, not as a separate pass. Otherwise all four tickets run in parallel.
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): save/load UI and code quality refactors" \
--description "body" --base main --head client
```
+74
View File
@@ -0,0 +1,74 @@
# Sprint 20: Shape — Joint Coordination
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
## Pre-Sprint Decisions
No blocking pre-sprint decisions are required. All selected tickets have their upstream decisions confirmed.
| Decision | Status | Impact |
|----------|--------|--------|
| D-087 (v0.1 triangle configuration) | Confirmed | Server #106/#107/#250 must produce T1-T5 triangle types |
| D-089 (self-contained forks, no cascade) | Confirmed | No cross-triangle state in `TriangleDef` or `TriangleState` |
| D-025 (social site as atomic template unit) | Confirmed | Server #163/#164/#165 schema shapes |
| D-020 (Godot = pure renderer) | Confirmed | Client refactors #557-#560 are motivated by this |
**One open question to monitor:**
- **Q-028 (line ID collision)** — resolved by D-084 (dual-namespace scheme), but the `RoleCounter` implementation is referenced in D-084 as a requirement for `server/src/content/npc_slug.rs`. Ticket #163 (role definition schema) should create the `server/data/templates/` directory structure; confirm with server team whether the slug counter module belongs in this sprint or the next.
## Sprint Completion Proof
The sprint is done when all of the following are observable:
1. **Template schema compiles and round-trips**: `cargo test -p server -- template` passes. A YAML file at `server/data/templates/sample_role.yaml` deserializes cleanly into a `RoleSchema` struct and re-serializes with identical content.
2. **Triangle generation produces valid state**: `cargo test -p server -- triangle` passes. A 4-NPC test world with 2 `TriangleDef` entries produces 2 `TriangleState` components with valid role assignments and tension values within the configured range.
3. **Triangle escalation fires events**: A unit test simulates 60 ticks on a triangle configured to escalate at tick 50, asserts `TriangleCrisisEvent` was emitted at the correct tick.
4. **Single-ownership model serializes**: A world with 2 templates and a cross-reference survives a save/load round-trip: `TemplateOwnership` components and `TemplateReferenceMap` entries are identical before and after.
5. **Refactors don't regress tests**: `make ci-client` passes with #557-#560 merged. `game_state.apply_snapshot()` contains no conditional accumulation logic. `dialogue_box.gd` has zero direct references to `GameState` or `AudioManager`. `main.gd` is under 350 lines.
6. **District layout design decided**: #153 produces a confirmed D-record specifying: complete district topology (how terminal, bar, smuggling corridors, and gate connect), tile dimensions per zone, access topology, and sightline constraints. Unblocks #155 (hand-crafted location authoring) and #188 (triangle instantiation).
## Test Plan Alignment (D-030)
Sprint 20 is Phase 3+ territory (D-030 sub-decision #8: Phase 3 = sprint 5+: CauseChain verification + divergent snapshots). The new template/triangle system introduces the first simulation structures that will eventually require CauseChain verification.
| Ticket | Test scope | Priority |
|--------|------------|----------|
| #163 | Unit: YAML round-trip, constraint validation | High |
| #164 | Unit: spec validation, tile count range | High |
| #165 | Unit: ownership component, reference map | High |
| #106 | Unit: triangle YAML round-trip, conflict validation | High |
| #107 | Unit: constraint satisfaction, minimum 2 triangles | High |
| #250 | Unit: escalation timing, crisis event emission, resolve command | High |
| #557-#560 | Regression: existing test suite must remain green | Medium |
| #153 | Design discussion: district layout confirmed as D-record | High |
The triangle system's `TriangleCrisisEvent` is the first event candidate for CauseChain integration. Do not wire CauseChain this sprint — but structure the event type so it can carry a `CauseChain` field in a future sprint without breaking callsites.
## Cross-Team Integration Points
| Server ticket | Client dependency | Notes |
|---------------|-------------------|-------|
| #165 (`TemplateOwnership` serialization) | None this sprint | Adds fields to `SaveStateV1` — no client protocol change needed until template data is rendered |
| #250 (`TriangleCrisisEvent` in `ObserverSnapshot`) | None this sprint | Event added to snapshot schema as stub — client rendering of triangle state is Sprint 21+ |
| Planning ticket | Downstream impact | Notes |
|-----------------|-------------------|-------|
| #153 (district layout design) | Unblocks #155, #188 | Layout decisions feed into Sprint 21 hand-crafted location authoring and triangle instantiation |
No live cross-team protocol dependencies this sprint. Server and client work in parallel.
## Deferred to Sprint 21
The following tickets are natural Sprint 21 candidates once this sprint's foundation lands:
- **#166** (Template-to-instance mapping) — instantiate templates into the world; requires #163+#164+#165
- **#161** (Template instantiation engine) — full NPC spawn from template; requires #163+#164+#165+#166
- **#159** (Tier 2 template definition format) — YAML schema for the full template document; blocked by #163
- **#108** (Cross-template triangle generation) — requires #106+#107
- **#109** (Triangle validation) — quality checks on generated triangles; requires #107
- **#155** (Hand-crafted location authoring) — requires #153 (station district layout design), which is being resolved this sprint on the planning branch
+82
View File
@@ -0,0 +1,82 @@
# Sprint 20: Shape — Planning Tasks
**Goal:** Resolve the station district layout design through structured discussion, producing a confirmed D-record that unblocks Sprint 21 location authoring and triangle instantiation.
**Branch:** `planning`
**Agents:** Gestalt (systems design), Miri (worldbuilding), Araminta (visual/spatial), Tyre (technical feasibility), Paula (narrative), Ozzie (player experience), Qatux (documenter), SI (project manager)
## Tickets
| # | Title | Type | Blocks |
|---|-------|------|--------|
| #153 | Station district layout design | design discussion | #155, #188 |
## Discussion Format
Ticket #153 is a **design discussion** — workshop-style, run on the planning branch. The output is a confirmed decision record (D-record) in `decisions/content.md` or `decisions/architecture.md`.
### Context: What Already Exists
Three spatial layouts have been authored (all by Araminta, Sprint 17):
- **The Terminal** (logistics hub): `docs/design/spatial-layout-terminal-v01.md` — 44×28 tiles, cool grey-navy
- **The Last Shift** (bar): `docs/design/spatial-layout-bar-v01.md` — 28×22 tiles, warm dark amber
- **Smuggling corridors**: `docs/design/spatial-layout-smuggling-corridors-v01.md` — overlay on terminal + bar + maintenance corridors
Station profile: `docs/design/sova-station-profile.md` — defines 6 districts, only Transit District is playable in v0.1.
Key decisions already confirmed:
- D-025: Social site / functional cluster as atomic template unit
- D-036: Sova Transit District / Krenn System as v0.1 setting
- D-050: Velen naming and climate
Open question: Q-036 (district skeleton as generator output) — relevant but not blocking; the v0.1 district is hand-authored.
### What #153 Must Decide
The individual locations exist as standalone layouts. What's missing is **how they connect** — the district as a whole:
1. **District topology**: How do the terminal, bar, gate corridor cluster, and smuggling hideout spaces relate spatially? What corridors connect them? What's the walking distance/time between key locations?
2. **Gate corridor cluster** (#157): The span gate area — customs, cargo staging, commuter flow. This is the district's entry point and a social chokepoint. Needs spatial spec at the same fidelity as the terminal and bar.
3. **Access topology**: Public → semi-restricted → restricted zones. How does the access gradient map across the whole district? Where are the boundaries the player must navigate?
4. **Sightline constraints**: Which locations have line-of-sight to which? This is gameplay-critical — the player's observation opportunities depend on where they can see from where.
5. **NPC traffic patterns**: How do NPCs flow through the district? Shift changes, commuter routes, social gathering patterns. The spatial layout determines what the player can observe by being in the right place at the right time.
6. **Total district dimensions**: What's the bounding box? How does tile count affect performance (server spatial queries, client rendering)?
### Discussion Rounds
**Round 1 — Inventory and constraints**
Each agent reviews the existing layouts and states what their domain requires from the district layout. Gestalt: gameplay loops that need spatial support. Miri: setting consistency, what the station profile implies. Araminta: visual continuity across zones, tilemap feasibility. Tyre: performance constraints, tilemap size limits. Paula: narrative beats that need specific spatial staging. Ozzie: navigation feel, does the district feel explorable and readable.
**Round 2 — Topology proposals**
Propose concrete district maps (ASCII or description). How do the existing layouts connect? Where does the gate corridor go? What fills the space between authored locations?
**Round 3 — Convergence**
Resolve conflicts, pick a topology, specify dimensions. Draft the D-record.
### Output
- A confirmed D-record specifying:
- District topology diagram (which locations connect to which, via what corridors)
- Approximate tile dimensions per zone and total district
- Access topology (public/semi-restricted/restricted gradient)
- Key sightline relationships
- Gate corridor cluster spatial spec (or a separate ticket if too large)
- Updated `decisions/` domain file
- Gate corridor layout doc at `docs/design/spatial-layout-gate-v01.md` if produced
### Reference Files
Read before starting:
- `docs/design/spatial-layout-terminal-v01.md`
- `docs/design/spatial-layout-bar-v01.md`
- `docs/design/spatial-layout-smuggling-corridors-v01.md`
- `docs/design/sova-station-profile.md`
- `decisions/content.md` — D-025 (social sites), D-036 (Sova setting)
- `decisions/architecture.md` — D-014 (tile-based movement)
- `decisions/perception.md` — D-059 (fog layers, zone temperature)
- `decisions/questions.md` — Q-036 (district skeleton as generator output)
+147
View File
@@ -0,0 +1,147 @@
# Sprint 20: Shape — Server Tasks
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 19
None. Sprint 19 treated as complete.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #163 | Role definition schema | — |
| #164 | Spatial requirement specification | — |
| #165 | Single-ownership model | — |
| #106 | Triangle definition schema | — |
| #107 | Intra-template triangle generation | — |
| #250 | Triangle escalation system | — (#103, #105 done) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-023 (three-tier content model: Tier 1 drama modules, Tier 2 templates, Tier 3 procedural), D-024 (NPC generation model: 10 axes, triangles as atomic social unit — 2 per template minimum), D-025 (social site / functional cluster as atomic template unit: 4-8 NPCs, 15-40 tiles, single-ownership with reference links), D-029 (population entanglement ratio: 30/50/20 — triangles are the 50% mundane layer)
- `decisions/scope.md` — D-087 (v0.1 triangle configuration: T1 Kael-Smuggler-Ring, T2 Sera-Detective-Commission, T4 Drin-System-Ring as active forks; T3 and T5 as passive tensions), D-089 (self-contained triangle forks for v0.1, no cross-triangle cascade)
- `decisions/architecture.md` — D-010 (deterministic simulation: BTreeMap for all collections, no HashMap), D-026 (simulation tiers: Active-tier NPCs are fully simulated; template instantiation populates Active tier), D-041 (KnowledgeGraph: per-entity component — template instantiation must assign KnowledgeGraph to each spawned NPC)
## Notes
### #163 — Role definition schema
The `RoleDefinition` struct already exists in `server/src/npc/generate.rs` as a procedural generation input — it defines `name`, location pool entries, and per-axis ranges. This ticket extends that to become the canonical Tier 2 role schema.
What this ticket must deliver:
- A `RoleSchema` type (new, distinct from `RoleDefinition`) in a new `server/src/content/template/` module (or `server/src/content/types.rs` extended). Fields: `role_id: RoleId` (newtype over String), `required_traits: Vec<PersonalityTrait>`, `skill_focus: Vec<Skill>`, `relationship_constraints: Vec<RelationshipConstraint>`, `routine_template: Vec<RoutineEntry>` — these are constraints fed into the NPC generator, not hardcoded values.
- A `RelationshipConstraint` type: `{ with_role: RoleId, kind: RelationshipKind, required_trust: TrustRange }`. Constrains who this role must be in relationship with within the same template.
- YAML deserialization via `serde`. Schema files will live at `server/data/templates/` (create the directory).
- Unit tests: round-trip YAML serialize/deserialize a sample role schema. Validate constraint logic (no self-referential constraints, no duplicate role_id within a template).
Integration points: `server/src/npc/generate.rs` (`RoleDefinition` → becomes a builder derived from `RoleSchema`), `server/src/content/types.rs` (existing content type infrastructure), `server/src/knowledge/types.rs` (`StableId`, `RelationshipKind`).
Gotcha: `RoleId` must be stable across save/load — it's a string slug, not a bevy `Entity`. Keep it a newtype over `String` so it serializes cleanly with `StableId`.
### #164 — Spatial requirement specification
No existing spatial specification type exists. This is greenfield within the template system.
What this ticket must deliver:
- A `SpaceSpec` type: `{ tile_count_min: u32, tile_count_max: u32, sightline_zones: Vec<SightlineZone>, privacy_level: PrivacyLevel, traffic_pattern: TrafficPattern }`.
- `SightlineZone`: a named sub-area with a coverage radius in sim tiles (0.5m each, per D-066). Example: `{ name: "bar_counter", radius: 4 }` — 4 sim tiles = 2m clear sightline.
- `PrivacyLevel` enum: `Public`, `SemiPrivate`, `Private`. Governs NPC behavior (NPCs are less likely to disclose secrets in Public spaces).
- `TrafficPattern` enum: `Thoroughfare`, `Destination`, `Restricted`. Governs procedural NPC routine routing through this space.
- YAML deserialization. Schema files co-locate with role schemas at `server/data/templates/`.
- Unit tests: sample spec round-trip, validation that min <= max tile count.
Integration points: `server/src/content/template/` (new module or extended `server/src/content/types.rs`), future chunk generation (`server/src/simulation/` — spatial specs will inform where templates are placed in the map). No simulation code changes needed this sprint — spec types only.
Gotcha: Tile counts are in sim tiles (0.5m). A 15-40 visual tile space (per D-025) = 30-80 sim tiles. Document this conversion explicitly in code comments to prevent future confusion.
### #165 — Single-ownership model
NPCs are owned by exactly one template, with reference links to others (D-025). No existing ownership component exists.
What this ticket must deliver:
- A `TemplateOwnership` ECS component: `{ template_id: TemplateId, role_id: RoleId }`. Assigned at template instantiation, never reassigned.
- A `TemplateId` newtype over `u64` — deterministic from world seed + template slug hash.
- A `TemplateReference` struct: `{ from_template: TemplateId, to_template: TemplateId, via_role: RoleId, relationship_metadata: RelationshipKind }`. Stored in a `TemplateReferenceMap` resource (a `BTreeMap<TemplateId, Vec<TemplateReference>>`).
- Logic for lifecycle coordination: when a template is unloaded (NPC tier drops to State-saved or Ungenerated per D-026), `TemplateReference` links are preserved in the serialized state, not destroyed.
- Unit tests: spawn two templates with cross-references, verify `TemplateReferenceMap` entries, verify `TemplateOwnership` components.
Integration points: `server/src/simulation/tier.rs` (tier transitions must preserve `TemplateOwnership`), `server/src/simulation/save_state.rs` (serialize `TemplateOwnership` and `TemplateReferenceMap` as part of `SaveStateV1` — add fields), `server/src/npc/generate.rs` (generator receives `TemplateId` + `RoleId` at spawn time).
Gotcha: `TemplateId` from seed + slug hash must be deterministic across save/load — use `StdHasher` is prohibited (non-deterministic), use a seeded hash (e.g., `std::hash::Hasher` from a fixed algorithm) or simply hash the slug string bytes with a fixed polynomial. Log the `TemplateId` computed value in tests for debugging.
### #106 — Triangle definition schema
The D-024 spec says triangles are the atomic unit of social intrigue — 2 per template minimum, 1 cross-template. No `TriangleDef` type exists anywhere in the codebase.
What this ticket must deliver:
- A `TriangleDef` type: `{ triangle_id: TriangleId, roles: [RoleId; 3], conflict_type: ConflictType, interest_axes: [NpcAxis; 3], relationship_constraints: Vec<RelationshipConstraint> }`. Three roles, each with a conflicting axis (Want, Secret, Tolerance, etc.).
- `ConflictType` enum based on D-087 active fork patterns: `ResourceCompetition`, `LoyaltyConflict`, `SecretExposure`, `AuthorityChallenge`. Passive tensions use `LatentTension` variant.
- `TriangleId` newtype over `u64` — deterministic from template seed + role triple.
- Validation: all three roles must be distinct within the template; the conflict type must map to at least one axis divergence (no conflict on identical axis values).
- YAML deserialization. Triangle definitions are authored as part of a template file or as a standalone `triangles.yaml` per template — Dudley to decide the co-location approach.
- Unit tests: sample triangle round-trip, validation for duplicate roles, validation for self-consistent conflict.
Integration points: `server/src/content/template/` (lives alongside `RoleSchema` and `SpaceSpec`), `server/src/npc/generate.rs` (the generator will consume `TriangleDef` in #107 to assign axis values that produce the desired conflict), `decisions/content.md` D-087 (v0.1 triangles T1-T5 should be expressible in this schema).
Gotcha: D-089 — self-contained triangles for v0.1, no cross-triangle cascade. Do not add cross-triangle state fields to `TriangleDef`. Cross-template triangles are expressed by a `TriangleDef` that references a `RoleId` from a different `TemplateId` — the cross-template link is in the role, not a special triangle type.
### #107 — Intra-template triangle generation
The template system can now describe triangles (#106). This ticket generates them from the description.
What this ticket must deliver:
- A `generate_intra_template_triangles(world: &mut World, template_id: TemplateId, defs: &[TriangleDef], rng: &mut SimRng) -> Vec<TriangleState>` function.
- `TriangleState` ECS component: `{ triangle_id: TriangleId, role_assignments: BTreeMap<RoleId, StableId>, tension: u8, phase: TrianglePhase }`. `tension` starts at a seeded value within a configured range. `TrianglePhase` enum: `Dormant`, `Simmering`, `Active`, `Resolved`.
- Constraint satisfaction: for each `TriangleDef`, assign generated NPCs (by `StableId`) to the three roles. Validate that the NPC's axis values satisfy the conflict (e.g., for a `LoyaltyConflict`, the NPC filling the `loyalty_torn` role must have a Relationships axis with entries for both of the other two roles).
- Minimum 2 triangles per template — emit an error (not a panic) if the template definition provides fewer than 2 `TriangleDef` entries.
- Unit tests: spawn a 4-NPC template, generate 2 triangles, assert `TriangleState` components exist and role assignments are valid, assert constraint satisfaction.
Integration points: `server/src/npc/generate.rs` (NPC generation runs first; triangle generation consumes the generated NPCs' axis values), `server/src/content/template/` (#106 types), `server/src/simulation/rng.rs` (`SimRng` for determinism).
Gotcha: Constraint satisfaction can fail if the NPC pool doesn't provide a suitable candidate for a role. Implement a fallback: if no NPC satisfies the strict constraint, pick the closest match and log a warning. Do not panic — world generation must be robust to imperfect seeds.
### #250 — Triangle escalation system
Blockers #103 (relationship dynamics) and #105 (tolerance threshold triggers) are done. `TriangleState` from #107 is available this sprint.
What this ticket must deliver:
- An ECS system `tick_triangle_escalation` that runs once per game-minute (every 10 ticks per D-031). For each `TriangleState` in `Simmering` or `Active` phase: increment `tension` by a seeded per-triangle rate (drawn from `SimRng` at world-gen time, stored on `TriangleState`). When `tension` exceeds the lowest `ToleranceThreshold` among the triangle's three NPCs, transition `phase` from `Simmering` to `Active`.
- Observable events: when a triangle enters `Active`, emit a `TriangleCrisisEvent` (new event type) containing `triangle_id`, `role_assignments`, and `trigger_npc: StableId`. The monologue system and knowledge system can subscribe to this event — but do not wire those subscribers this sprint. Emit the event; downstream consumption is future work.
- `Resolved` transition: when the player resolves an active fork (mechanism TBD — stub a `ResolveTriangle(TriangleId)` command for now), set `phase = Resolved`. D-089: resolution does not cascade.
- Unit tests: simulate 60 ticks on a triangle with a known tension rate, assert `Active` transition at the expected tick. Test `Resolved` command sets phase correctly.
Integration points: `server/src/simulation/tier.rs` (`tick_triangle_escalation` only runs on Active-tier NPCs per D-026), `server/src/simulation/time.rs` (game-minute scheduler — 10-tick interval), `server/src/npc/tolerance.rs` (`ToleranceThreshold` component), `server/src/npc/relationships.rs` (`RelationshipGraph` — tension rate influenced by relationship stress), `server/src/bridge/types.rs` (add `TriangleCrisisEvent` to `ObserverSnapshot` for future client rendering).
Gotcha: Different seeds produce different tolerance thresholds — the same triangle template can escalate in 5 minutes or 30 minutes depending on the seed. This is intentional (D-087). Do not hardcode a tension rate — it must come from `SimRng` at world-gen time and be stored on the component.
## Dependency Chain
```
#163 (Role definition schema) ─┐
#164 (Spatial requirement spec) ├─ parallel, no inter-dependency
#165 (Single-ownership model) ─┘
│
└─ feeds #166 (Template-to-instance mapping, Sprint 21)
#106 (Triangle definition schema) ──► #107 (Intra-template generation) ──► #250 (Escalation system)
│
└─ feeds #108 (Cross-template generation, Sprint 21)
```
#163/#164/#165 and #106/#107/#250 are two parallel tracks. All six tickets can begin in week 1; #107 and #250 gate on #106 completing first.
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): social site template schema and triangle system" \
--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,142 @@
# Generator Architecture Workshop Brief
**Goal:** Establish the top-down procedural generator pipeline architecture — from geography down to individual chunk fill — that will power the 300-world model. Produce a D-record defining generator primitives, the spatial hierarchy, and the sub-chunk building system.
**Ticket:** #562 (story, parent: #50 Chunk-based Map System)
**Priority:** MEDIUM — prerequisite to #144 (Chunk generation system), Sprint 21+ target
**Participants:** Gestalt (systems design), Tyre (architecture), Miri (worldbuilding), Araminta (spatial/visual), Nigel (replayability/procedural gen), Qatux (docs), SI (tickets)
**Source:** Station District Layout Workshop (#153, Round 3), lead directive
---
## Context
The v0.1 Transit District is hand-authored. The long-game goal is 300 procedurally generated worlds. The question this workshop must answer is: **what is the generator's architecture — top to bottom?**
The lead has identified a top-down pipeline model inspired by Cities Skylines:
```
Geography
→ Infrastructure (transport nodes, utilities)
→ Amenities & Services
→ Population (extrapolated from capacity)
→ Zoning
→ Block generation
→ Chunk fill (individual buildings and spaces)
```
This workshop also addresses a related structural question: how does the **sub-chunk quarter system** provide building variety within template-driven generation? A chunk divides into 4 quarters that can merge, split, leave gaps, or host shacks/gardens — producing L-shapes, mixed-use footprints, and irregular structures without breaking the generator's regularity.
Multi-block structures (train stations, government buildings, stadiums, parks, farmland) span multiple chunks and must be accounted for in the block and zoning passes before individual chunks are filled.
**Starting point:** Q-036 asks whether the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) is the atomic generator output unit. D-025 defines social sites as atomic template units for hand-authoring. This workshop must reconcile the two.
**What is already decided:**
- D-025: Social site / functional cluster as atomic template unit (hand-authoring)
- D-036: Sova Transit District as v0.1 setting (hand-authored)
- D-012: Chunk-based map system (bounded for v0.1, borderless-capable)
- #153 D-record (Station District Layout Workshop): district/block/chunk spatial hierarchy, spatial dimensions, access topology gradient — reference `decisions/content.md` for the confirmed record
**What is still open:**
- Q-036: District skeleton as generator output (assigned Tyre, Gestalt)
- Q-037: Generator development pipeline / phased production model (assigned SI, Tyre)
- Q-039: Procedural gate topology generation
---
## Key Questions to Resolve
### 1. Pipeline Architecture
1. Is the Cities Skylines top-down model (geography → zoning → chunk fill) the right architecture for The Settled Reach, or does the game's station-centric setting require a different ordering?
2. What is the correct sequence — does population follow zoning, or precede it?
3. How does the pipeline handle stations (Sova) vs. planet-side cities vs. orbital installations? Same pipeline, different geography inputs?
4. Where do political/economic conditions enter the pipeline? (Faction control, prosperity tier, trade routes)
5. At what pipeline stage are the triangle templates (D-025) instantiated?
### 2. Spatial Hierarchy and Primitives
6. What are the canonical spatial units in the hierarchy? (Region → District → Block → Chunk → Sub-chunk quarter? Or different names/levels?)
7. What is the confirmed chunk size in sim tiles? (Reference #153 D-record — the district workshop decided this)
8. What is the block size — how many chunks per block?
9. What is the district size — how many blocks per district?
10. How does the sub-chunk quarter system work mechanically? (Quarter = ¼ chunk, can merge 2×2, 1×2, L-shape. What are the merge rules? Who decides fill vs. empty?)
### 3. Multi-Block Structures
11. How are multi-block structures (train stations, stadiums, government complexes, parks) represented in the generator? (Reserved footprint at the zoning pass? Pre-baked templates that claim N×M blocks?)
12. What is the maximum multi-block footprint — is there a cap?
13. How do multi-block structures interact with neighbouring chunk fills at their edges?
14. Can a multi-block structure span district boundaries?
### 4. District Skeleton as Generator Output (Q-036)
15. Is the district skeleton (social site arrangement, NPC slot allocation, triangle template selection, access topology) the correct atomic output of the district-generation stage?
16. How does the district skeleton output interact with D-025's social site templates? (Generator selects and arranges templates, not individual tiles?)
17. What inputs does the district skeleton generator consume? (Zoning type, population density, faction control, economic function, transport adjacency)
18. What does the district skeleton output look like as a data structure? (List of social site slots with positions, access tier, NPC capacity, template tag)
### 5. Replayability and Variation
19. What variation levers exist at each pipeline stage? (Seed, faction weights, economic tier, historical events?)
20. How does the sub-chunk quarter system produce perceived variety across multiple playthroughs?
21. How are "flavour" structures (shacks, gardens, market stalls) assigned to unclaimed quarter space?
22. What prevents two generated districts from feeling identical even if they share the same zoning type?
### 6. v0.1 / Generator Boundary
23. Where does the v0.1 hand-authored content end and the generator begin? What stub interfaces must v0.1 leave behind?
24. Which pipeline stages are in scope for implementation, and which are deferred (per Q-037's phased production model)?
25. Does the v0.1 Transit District need to be expressible as generator output (for validation), or is it purely an authored ground-truth?
---
## Input Documents
| Document | What to read | Why |
|----------|-------------|-----|
| `decisions/content.md` | D-025 (social site template), #153 D-record | Generator must compose from these primitives |
| `decisions/scope.md` | D-012 (chunk system), D-036 (Sova setting) | Spatial constraints and v0.1 setting |
| `decisions/questions.md` | Q-036 (district skeleton), Q-037 (generator pipeline), Q-039 (gate topology) | Open questions this workshop resolves |
| `docs/design/sova-station-profile.md` | District types, 6-district layout | The worldbuilding context the generator must reproduce |
| `docs/design/spatial-layout-terminal-v01.md` | Terminal layout | Example of a hand-authored chunk cluster |
| `docs/design/spatial-layout-bar-v01.md` | Bar layout | Example of a hand-authored chunk cluster |
| `decisions/architecture.md` | D-014 (tile-based movement), D-012 (chunk loading) | Technical constraints on spatial units |
| `docs/workshops/content-architecture/content-architecture-workshop-brief.md` | Three-tier content pipeline | How templates and procedural generation already relate |
---
## Expected Outputs
1. **D-record: Generator Architecture** — confirmed in `decisions/architecture.md`:
- Top-down pipeline stages (named and sequenced)
- Spatial hierarchy (named levels, tile dimensions per level)
- Sub-chunk quarter system rules (merge/split/fill logic)
- Multi-block structure reservation protocol
- District skeleton as generator output: data structure definition
- v0.1 / generator boundary (what's hand-authored, what's stubbed)
2. **Resolution of Q-036:** District skeleton as atomic generator output — yes/no + formal definition if yes
3. **Resolution of Q-037 scope:** Which pipeline phases land in which version window (v0.1 stub, v0.2–0.5 template expansion, v0.6–0.10 generator development)
4. **Tickets:** Implementation tasks derived from the D-record (chunk data structure update, district skeleton schema, zoning pass stub, block generation stub)
---
## Workshop Format
Three rounds:
**Round 1 — Domain Inventory**
Each participant reviews existing decisions and states what their domain requires from the generator architecture.
- Gestalt: what gameplay loops does the generator need to support? What must it guarantee (e.g., always a surveillance chokepoint, always a quiet zone)?
- Tyre: what are the hard technical constraints on chunk size, hierarchy depth, and data structure for the district skeleton?
- Miri: how does the generator reproduce the cultural/economic variation of 300 worlds? What lore-level inputs drive the pipeline?
- Araminta: what visual coherence constraints does chunk fill need to satisfy? How does the sub-chunk quarter system produce plausible streetscapes?
- Nigel: what variation and replayability guarantees must the generator provide? What makes two generated districts feel different?
**Round 2 — Pipeline Proposals**
Propose concrete pipeline architecture. Name the stages, define the spatial hierarchy levels with tile dimensions, describe the district skeleton data structure. Respond to each other's domain requirements from Round 1.
**Round 3 — Convergence**
Resolve conflicts, agree on the pipeline sequence, lock spatial hierarchy dimensions, define the district skeleton output format, set the v0.1/generator boundary. Draft the D-record.
@@ -0,0 +1,90 @@
# Workshop Outcomes: Knowledge Graph & Information Boundaries
**Workshop:** Knowledge Graph & Information Boundaries
**Date:** 2026-02-11
**Rounds:** 2 (Design + Synthesis)
**Participants:** Tyre, Gestalt, Paula, Dudley, Si
**Facilitator:** Jeroen
**Documenter:** Qatux
**Status:** DONE — fully actioned, decisions filed, tickets created
**Full notes:** `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`
---
## What the Workshop Accomplished
Replaced the `InformationInventory { known_facts: Vec<String> }` placeholder with a fully specified knowledge graph data model. Five agents analyzing from different angles converged independently on all fundamentals: per-entity ECS component, stable entity IDs, per-entry provenance tracking. The synthesis resolved the only substantive debate (centralized resource vs. per-entity component) unanimously in favour of per-entity. The resulting D-041 spec is the foundation for asymmetric information as a playable mechanic — and became load-bearing for D-010, D-011, D-017, D-028, D-033, and Q-016.
---
## Major Decision Produced
### D-041: Knowledge Graph Data Model
The core design decision of this workshop. Full specification in `decisions/perception.md`.
Key architectural choices:
- `KnowledgeGraph` as a Bevy ECS `Component` on each entity (not a centralized resource)
- `StableEntityId` (`u64`-based) for cross-reference stability; runtime `EntityRegistry` for bidirectional mapping
- `BTreeMap<StableId, EntityKnowledge>` + `BTreeMap<FactId, FactKnowledge>` per entity
- Four confidence levels: `Direct` > `KnowsDetails` > `KnowsOf` > `Suspects`
- Three knowledge states: `Active`, `Stale`, `Contradicted`
- `KnowledgeSource` tracked per-entry (not per-graph): `DirectObservation`, `ToldBy`, `Background`, `Heard`
- Event-driven updates via `KnowledgeEventQueue`; decay pass once per game-minute
- Sprint 2 scope: data structures + direct observation + basic decay only
### Additional Decisions Implied (formalized later)
The workshop produced the architectural foundation that fed into:
- D-012: Information boundaries (per-character knowledge isolation)
- Formal resolution of Q-016: Knowledge hierarchy (`Suspects` < `KnowsOf` < `KnowsDetails` < `Direct`)
---
## Open Questions Identified
| ID | Question | Sprint Impact | Resolved By |
|----|----------|---------------|-------------|
| Q-024 | Gossip propagation timing (immediate vs queued) | Sprint 3+ | D-080 (knowledge-flow-npc-boundaries workshop) |
| Q-025 | Knowledge graph cap and eviction policy | Sprint 3+ | D-080 (closed: no cap needed at projected v0.1 scale) |
| Q-026 | Contradiction detection algorithm | Sprint 3+ (THE FRIEND arc) | D-083 (knowledge-flow-npc-boundaries workshop) |
None blocked Sprint 2.
---
## Tickets Created
8 new tickets added to Sprint 2, all under epic #351. Sprint 2 expanded from 14 to 22 tickets (+6.5 developer-days).
| # | Title | Priority | Estimate |
|---|-------|----------|----------|
| #361 | KnowledgeGraph component + types (D-041) | critical | 1 day |
| #362 | StableEntityId + EntityRegistry resource | critical | 1 day |
| #363 | KnowledgeEventQueue + processing system | high | 0.5 day |
| #364 | Direct observation knowledge flow | critical | 0.5 day |
| #365 | Basic knowledge decay system | high | 0.5 day |
| #366 | Observer snapshot knowledge integration | critical | 1 day |
| #367 | Knowledge graph unit test suite | high | 1 day |
| #368 | Knowledge vocabulary for v0.1 content | high | 0.5 day |
**Existing tickets affected:**
- #89 (Information inventory) — cancelled, subsumed by #361
- #269 (CauseChain component) — marked done (already implemented)
- #138-142 (Information boundary epics) — reparented under #351; #139, #141, #142 deferred to Sprint 3; #140 cancelled (merged into #366)
**Critical path impact:** #361, #362, #363, #364 added serially to Sprint 2 critical path (10 tickets serial, up from 6).
---
## Sprint 2 Completion Criteria (Added by Workshop)
Two new acceptance criteria added to Sprint 2's definition of done:
- Entity color reflects relationship state from knowledge graph (#361, #366)
- Remembered (not visible) entities appear as ghosts at last-known position (#361, #366)
**Knowledge graph proof:** Observe an NPC, walk away, return. NPC appears as ghost at last-known position while not in LOS. Color shifts by relationship state.
---
*Compiled by Qatux. Source: `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`. Primary decision in `decisions/perception.md` (D-041).*
@@ -0,0 +1,116 @@
# Workshop Outcomes: v0.1 Content Scoping
**Workshop:** v0.1 Content Scoping
**Date:** 2026-02-12
**Rounds:** 2 + closing round (lead resolutions)
**Participants:** Gestalt, Paula, Tyre, Mellanie, Stig, Dudley, Si, Qatux
**Facilitator:** Jeroen
**Documenter:** Qatux
**Status:** CLOSED — all recoverable decisions filed, tickets created
**Full notes:** `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`
---
## What the Workshop Accomplished
Applied the Wiki Review's long-term generator strategy to the immediate v0.1 hand-authored proof. Produced 20 decisions (D-042 through D-061), 38 new tickets, canonical NPC mapping for 17 characters, the 16-key EntityKnowledge specification, full content directory architecture, and a Sprint 3-5 roadmap. Tyre and Dudley independently produced structurally identical ObserverSnapshot v3 definitions without coordination — confirmed the architecture was sound. Lead issued 4 decisions resolving the major Round 1 disagreements between rounds, then resolved 3 remaining questions in a closing round.
**Note on decision IDs:** Several IDs assigned at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-087 (content directory structure), D-089 (NPC canonical mapping method), D-091 (EntityKnowledge 16-key canonical set), Q-031 through Q-034.
---
## Decisions Produced
### From Round 1 Consensus (7)
| ID | Decision | Domain |
|----|----------|--------|
| D-042 | Drin promoted from Tier 3 to Tier 2 | content.md |
| D-043 | THE NOBODY mechanic deferred to v0.2; hidden data ships in v0.1 content | scope.md |
| D-044 | v0.1 interaction model: 7 verbs (Move, Look, Monologue, Examine Object, Examine NPC, Talk, Overhear) | scope.md |
| D-045 | v0.1 scope IN: news ticker, PC-as-NPC, time progression, relationship state transitions | scope.md |
| D-046 | v0.1 scope OUT: inventory, stealth, combat, save/load, lattice modification | scope.md |
| D-047 | v0.1 triangles: 3 active forks (T1, T2, T4), 2 passive tensions (T3, T5) | content.md |
| D-048 | Client receives all text from server via state updates; client does not load content files | architecture.md |
### From Round 2 + Closing (13)
| ID | Decision | Domain |
|----|----------|--------|
| D-049 | YAML is the content file format for v0.1; RON is optional build-time optimization | architecture.md |
| D-050 | Gestalt's NPC pattern/motivation mapping canonical for v0.1; Paula's emotional layer becomes v0.2 annotations | content.md |
| D-051 | v0.1 ships single context-sensitive verb; multi-verb architecture modeled underneath | architecture.md |
| D-052 | 3-state pause: Normal (100%), Overlay (50%), Paused (0%); server-authoritative | architecture.md |
| D-053 | Self-contained triangle forks for v0.1; no cross-triangle cascade (v0.2) | content.md |
| D-054 | ObserverSnapshot v3: adds sim_speed, nearby_interactions, active_dialogue, monologue, overheard, knowledge_updates, examine_result, ticker_headlines | architecture.md |
| D-055 | 16 EntityKnowledge keys; 4 new role-perspective keys; trust_read merged into trust_level; secret_held → leverage_held | architecture.md |
| D-056 | PC voice registers: smuggler (feeling-first, fragments, physical); detective (analysis-first, complete sentences, institutional) | content.md |
| D-057 | Content directory: content/ with _schema/, global/, districts/ top-level split; JSON Schema validation at build time | process.md |
| D-058 | THE FRIEND content pack template: Kael Davan, 91 lines across 5 arc phases | content.md |
| D-059 | Monologue display: 160 char max, 2-line max, 4-6s display, 2s cooldown, queue depth 1, 9-level priority | architecture.md |
| D-060 | actions[] renamed to verbs[] across all surfaces | architecture.md |
| D-061 | No ticket merges across domain teams | process.md |
### Retrospective Filings (ID collisions resolved)
| ID | Decision | Domain |
|----|----------|--------|
| D-087 | Content directory structure (content/ split) | process.md |
| D-089 | Canonical NPC pattern/motivation mapping method | content.md |
| D-091 | EntityKnowledge 16-key canonical specification | architecture.md |
---
## Open Questions Carried Forward
| ID | Question | Status |
|----|----------|--------|
| Q-031 | NPC surnames for Drin, Sess, Tav awaiting Miri validation | Informational |
| Q-032 | Interaction struct naming: AvailableActions (Tyre) vs EntityInteractions (Dudley) | Resolved at implementation |
| Q-033 | 695 authored items: validated as scope input but not independently verified | Informational |
| Q-034 | Dialogue max-width: pixel value for 20% height / max-width constraint | Pending lead call |
None blocked Sprint 3.
---
## Tickets Created
38 new tickets + 10 existing ticket updates. See `si-ticket-changes.md` for full list.
**Teams:** copy (21), server (13), client (2), ci (1).
**Critical path:** #261 (Dual Lens Authoring Guide) is the single biggest blocker — directly blocks 9 downstream tickets across the content pipeline. Five-day time-box recommended.
| Series | Count | Domain |
|--------|-------|--------|
| A (Wiki content fixes) | 8 | copy |
| B (Style guides + specs) | 5 | copy |
| C (Content directory + schemas) | 10 | copy/server/ci |
| D (Design specs) | 2 | server/copy |
| NEW 1-7 (Workshop rounds) | 7 | copy |
| NEW 8-14 (Lead decisions, excl. killed NEW-12) | 6 | server/client |
**Killed:** NEW-12 (client pause state machine — pause is server-authoritative, client sends IPC command only).
**Sprint allocation:** Sprint 3 — foundations and specs. Sprint 4 — content conversion and authoring begins. Sprint 5+ — content at scale.
---
## NPC Canonical Mapping (17 NPCs)
The Gestalt-Paula synthesis produced the v0.1 canonical pattern/motivation mapping for all 17 Sova NPCs. This is the authoritative reference for content authoring.
| Name | Tier | Pattern | Motivation |
|------|------|---------|-----------|
| Kael Davan | T1 | FRIEND | OPERATOR |
| Sera Venn | T1 | FRIEND | WITNESS |
| Naia Tamm | T1* | MIRROR | CIVILIAN |
| Voss, Lera, Torek, Devra, Maret, Resha, Drin, Renn, Pell, Harek | T2 | (varied) | (varied) |
| Sess, Olin, Sabel, Tav | T3 | (varied) | (varied) |
Off-stage: Nils Davan — GHOST + HANDLER.
---
*Compiled by Qatux. Source: `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`. Decisions in relevant `decisions/` domain files (D-042 through D-061, D-087, D-089, D-091). Open questions in `decisions/questions.md` (Q-031 through Q-034).*
@@ -0,0 +1,97 @@
# Workshop Outcomes: v0.1 Gap Analysis
**Workshop:** v0.1 Gap Analysis
**Date:** 2026-02-11
**Rounds:** 2 (Gap identification + Synthesis)
**Participants:** Gestalt, Tyre, Ozzie, Paula, Nigel, Gore, Hoshe
**Facilitator:** Jeroen
**Documenter:** Qatux
**Status:** DONE — fully actioned, decisions confirmed, tickets created
**Full notes:** `docs/workshops/v01-gap-analysis/si-ticket-changes.md`
---
## What the Workshop Accomplished
Stress-tested the full v0.1 plan (232 tickets, 7 initiatives) against three tracks: strength of concept proof, fun and wow factor, and completeness. Found significant gaps: missing infrastructure (collision, pathfinding, time system), a missing experience layer (observation pipeline, interaction verbs), and systemically underpriced tickets across social, dialogue, and replayability systems. The workshop ended with 273 tickets (+41) and a clear Sprint 1-5 dependency chain.
Key outcome: the workshop revealed that tile collision, A* pathfinding, the observation event generator, and the interaction dispatcher were absent from the ticket catalog despite being architectural blockers for almost everything else. These were added at critical priority.
---
## Decisions Confirmed
The workshop confirmed 6 decisions. These fed directly into later formal D-records:
| Decision | Formal Record | Notes |
|----------|---------------|-------|
| Deterministic replay is an architectural requirement, not just test infrastructure | D-010 (reinforced) | Promoted #201 to critical |
| Time system: 10 tps, 4 day phases, diegetic clock | D-031 | Resolved Q-009; ticket #25 renamed and promoted |
| Godot test framework = gdUnit4 | process.md | Tyre reversed GUT recommendation after Hoshe's analysis |
| IPC testing = three-layer architecture (fixture, protocol mock, real integration) | architecture.md | Hoshe + Tyre |
| CauseChain is a production component, not test pollution | architecture.md | #269 created |
| Dual Lens Authoring Guide must precede content authoring | D-038 extended | All agents converged |
These decisions fed into the scope and architecture domain files. The three-tier content system (D-023), vertical slice (D-027), and population ratios (D-029) were validated against the gap analysis findings and confirmed as sound.
---
## Ticket Changes
**Before:** 232 tickets | **After:** 273 tickets (+41)
| Change Type | Count |
|-------------|-------|
| Priority promotions (existing) | 15 |
| New epics | 3 |
| New stories | 38 |
| Dependency records added | 21 |
| Renamed + promoted | 1 (#25) |
### Priority Promotions (15 tickets)
**Medium → High (11):** #103 (relationship dynamics), #105 (tolerance threshold triggers), #171 (trust-gated gossip), #172 (unprompted disclosure), #173 (trait modifier system), #175 (entanglement ratio), #176 (NPC pool generation), #121 (character voice variation), #126 (medium-range visual indicators), #162 (storyteller module activation), #178 (seed-based variation).
**High → Critical (3):** #201 (deterministic replay), #182 (divergent starting knowledge), #183 (divergent relationships).
### New Epics (3)
| ID | Title | Priority |
|----|-------|----------|
| #233 | Movement & Collision | critical |
| #234 | Observation & Interaction | critical |
| #235 | Game State Management | high |
### Critical New Stories
| ID | Title | Priority |
|----|-------|----------|
| #236 | Tile collision system | critical |
| #239 | Observation event generator | critical |
| #240 | Player interaction system and dispatcher | critical |
| #237 | Tile-based A* pathfinding | high |
| #238 | NPC path following and movement | high |
| #253 | Monologue content architecture | high |
| #261 | Dual Lens Authoring Guide | high |
| (+ 31 more stories) | | high/medium/low |
### Critical Path Produced
```
Sprint 1: #236 (collision) → #237 (pathfinding) → #238 (NPC movement)
#25 (game clock) → #88 (daily routines)
Sprint 2: #239 (observation generator) + #240 (interaction dispatcher)
Sprint 3: Observation verbs, NPC conversation, monologue, triangle escalation
Sprint 4: Content pipeline (#261 dual lens guide blocks all content packs)
Sprint 5: Validation and playtest protocol
```
---
## Open Questions
None formally raised as Q-records by this workshop — the gap analysis was primarily a ticket and priority exercise. Questions about content authoring (Q-012 through Q-017) were raised in the companion content-gap-analysis workshop the same day.
---
*Compiled by Qatux. Source: `docs/workshops/v01-gap-analysis/si-ticket-changes.md`. Decisions confirmed fed into `decisions/scope.md` (D-023, D-027, D-029) and `decisions/architecture.md` (D-010, D-031).*
@@ -0,0 +1,116 @@
# Workshop Outcomes: Wiki Review & Content Standards
**Workshop:** Wiki Review & Content Standards
**Date:** 2026-02-12
**Rounds:** 4 + lead interview between R3 and R4
**Participants:** Paula, Mellanie, Miri, Gestalt, Gore, Nigel, Ozzie, Tyre, Qatux, Si
**Facilitator:** Jeroen
**Documenter:** Qatux
**Status:** CLOSED — all recoverable decisions filed, tickets created
**Full notes:** `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`
---
## What the Workshop Accomplished
Began as a v0.1 wiki review (45 files, ticket #368) and was redirected between rounds 2 and 3 by a major strategic reframe: the lead declared the target as 300 populated worlds before DLC. This transformed the workshop from a content authoring strategy into a generator specification strategy. All Round 4 responses independently arrived at the same conclusion: the workshop had been designing generator specifications all along. The district skeleton, NPC composition matrix, Sacred/Profane framework, and pool architecture were already generator-shaped.
**The strategic reframe:** Old model: writers produce districts, tooling accelerates writers. New model: engineers produce generators, writers produce generator inputs, tooling IS the product.
**Note on decision IDs:** Several IDs proposed at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-088 (300-world generator model), D-090 (three-tier world authoring), D-092 (Sacred/Profane/Middle Kingdom framework), Q-030, Q-035 through Q-039.
---
## Decisions Produced
### Long-Term Strategy Decisions (Confirmed by Lead)
| ID | Decision | Domain |
|----|----------|--------|
| D-088 | 300 worlds before DLC — generator model required | scope.md |
| D-090 | Three-tier world authoring: Landmark (10-15 hand-authored), Regional (30-50 template), Generated (230-260 procedural) | scope.md |
| D-092 | Sacred/Profane/Middle Kingdom randomization framework | architecture.md |
### Supporting Decisions (Rounds 1-2, v0.1 Specific)
| ID | Decision | Domain |
|----|----------|--------|
| Q-030 | Cultural ingredients menu: 6 ingredient categories, Heritage OPTIONAL, derivation function produces cultural parameters | pending formalization |
| — | Hael renamed to Naia Tamm (resolves Kael/Hael sonic collision) | content.md |
| — | Three-system NPC architecture: Thematic Patterns + Functional Motivations + Composition rules | content.md |
| — | Cultural brief = generation seed (seed.yaml + brief.md dual artifact) | process.md |
| — | 8 PC archetypes at v1.0; archetypes are FLUID positions, not classes | scope.md |
| — | THE NOBODY: dynamic tier promotion (NOBODY → NOTICED → RECOGNIZED → KNOWN → INVESTED) | scope.md |
The specific v0.1 decisions (Naia rename, NPC architecture, THE NOBODY, THE MIRROR) were carried forward and filed in the subsequent v0.1 Content Scoping workshop where IDs were formally assigned.
---
## Open Questions Raised
| ID | Question | Status |
|----|----------|--------|
| Q-030 | Cultural ingredients menu: full formalization of 6 categories, null-heritage behavior, derivation function specification | Pending |
| Q-035 | Naming algorithm: phonetic rules vs word lists — which approach for 300-world cultural naming | Pending |
| Q-036 | Gate topology design: connectivity requirements, hub placement, small-world properties | Pending |
| Q-037 | Storyteller cultural literacy: how storyteller adapts pacing to cultural trust-building rates | Pending |
| Q-038 | NPC pattern composition rules: forbidden/preferred combination formalization | Pending |
| Q-039 | Modding toolkit: content pack manifest, ADD/REPLACE/MERGE overlay operations | Pending |
---
## Tickets Created
29 new tickets (1 epic, 9 stories, 19 tasks) + 4 existing ticket updates. See `si-ticket-changes.md` for full list.
**Teams:** copy (21), server (7), ci (1).
### Existing Ticket Updates (4)
| ID | Action |
|----|--------|
| #301 | Update description with concrete taxonomy rules from workshop |
| #319 | Update — Miri's Krenn brief supersedes original scope |
| #368 | Mark done — wiki at wiki/ is the delivered output |
| #261 | No change — confirmed still blocking, assign to copy when ready |
### New Epic
**Wiki Review Workshop Outputs** — parent epic for all workshop-produced tickets. Priority: high. Team: copy.
### Selected New Tickets
| Series | Count | Examples |
|--------|-------|---------|
| A (Wiki content updates) | 12 | A1 canonical names, A2 Hael→Naia rename, A3 Krenn brief, A7 smuggler attributes, A11 Triangle 1 fix |
| B (Style guides + specs) | 5 | B1 NPC Authoring Style Guide, B2 MIRROR spec, B3 PC-as-NPC spec, B4 Smuggler voice card |
| C (Content directory + schemas) | 10 | C1 design doc, C2 directory skeleton, C3 schemas, C9 validate-content CLI |
| D (Design specs) | 4 | D1 cultural_gate design, D2 seed config schema, D3 secondary contraband, D4 news ticker |
---
## Deferred to Future Workshops
The wiki-review triggered scoping of several follow-on workshops:
- **Control & Interaction Scheme** — brief written during this workshop; held separately (`docs/workshops/control-interaction/`)
- Perception system gravity variation for world transitions
- Full NPC pattern composition rules (forbidden/preferred lists)
- Gate topology design
- Storyteller cultural literacy
- LLM-assisted content pipeline design
- Modding toolkit specification
- Inter-world political generation
---
## Key Quotes Preserved
- Nigel: "Two players, same world, same seed, different experience because they noticed different people."
- Gore: "Learning to see is the endgame."
- Gestalt: "Hotline Miami movement + Disco Elysium interaction."
- Gore: "The theme is the field, not any specific phrasing of it. The field is: what does it cost to be human inside something bigger than yourself?"
---
*Compiled by Qatux. Source: `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`. Decisions in `decisions/scope.md` (D-088, D-090) and `decisions/architecture.md` (D-092). Open questions in `decisions/questions.md` (Q-030, Q-035 through Q-039).*

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