Compare commits

...
89 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 4e53a2e979 chore(meta): release v0.1.13
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:07:35 +01:00
jpmschweitzerandClaude Opus 4.6 1b08734838 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:53:02 +01:00
jpmschweitzerandClaude Opus 4.6 16bc87c590 docs(sprints): add Sprint 14 "Live" briefings — 22 tickets across 4 teams
NPC mood, trust, routine execution, NPC-to-NPC conversations with D-078
occlusion, monologue display, content line pools, and visual specs.
Teams: server (7), client (3), copy (6), visual (6).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:52:50 +01:00
jpmschweitzerandClaude Opus 4.6 f6eb92cb25 feat(docs): add D-078 — overheard NPC conversation via passive dialogue panel
Server-authoritative stochastic per-word occlusion filter for NPC-to-NPC
conversations. Dialogue panel in passive mode displays pre-occluded text;
monologue remains a clean internal response system. Indexes updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:52:44 +01:00
jpmschweitzerandClaude Opus 4.6 e11d54ff5c fix(assets): add missing .import file for sfx_npc_murmur.ogg
Godot requires .import files for stable uid:// references across
editor sessions. All other audio assets have one — this was missed
in the audio branch PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:48:42 +01:00
jpmschweitzer cf3349b909 Merge remote-tracking branch 'origin/audio' 2026-02-19 18:46:53 +01:00
jpmschweitzer bc1cfabb68 Merge remote-tracking branch 'origin/client' 2026-02-19 18:45:54 +01:00
jpmschweitzerandClaude Opus 4.6 8985340bdd fix(client): address PR #47 review — murmur deferral, zone extraction, 6 tests
Remove premature Voice/VoiceConversation from SOUND_EVENT_ASSETS (D-072
defers murmur to end-to-end sprint). Extract current_zone_id in
GameState.apply_snapshot() as first-class field, eliminating O(N) tile
scan in main.gd (D-020 server-authoritative). Add dir.list_dir_end()
after registry scan. Add rapid zone-crossing + _load_prefs() roundtrip
tests. Enhance comments on hub/workplace same-asset pattern, station
base hum, and confrontation dip replacement semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:37:23 +01:00
jpmschweitzer d2d538f4a8 Merge remote-tracking branch 'origin/server' 2026-02-19 18:36:48 +01:00
jpmschweitzerandClaude Opus 4.6 f57560ebe7 docs(meta): add tea comment hanging workaround to CLAUDE.md
tea comment hangs with inline heredocs and multi-line strings.
Document the two-step pattern: write to .tmp/ first, then pass
via $(cat).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:34:39 +01:00
jpmschweitzerandClaude Opus 4.6 dd1dd0feec fix(simulation): address PR #46 review — zone tests, doc accuracy, TBD comment
Add 3 zone enrichment tests to observer pipeline (tile inside zone,
tile outside zone, absent ZoneMap resource). Fix misleading Decay
Observatory doc comment that claimed Direct start when test starts at
KnowsDetails. Add production population TBD note on ZoneMap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:22:48 +01:00
jpmschweitzerandClaude Opus 4.6 513682522f test(audio): Sprint 13 test suite + lambda capture fix (#528-#533)
57 new tests in test_audio_sprint13.gd covering bus setup, dip profiles,
zone crossfade, recognition chime, and murmur wiring. Fix lambda capture
bug in 3 signal tests (String → Array wrapper for reliable closure
capture in Godot 4). Add bus volume reset to before_test() for test
isolation against prefs-loaded state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:19:32 +01:00
jpmschweitzerandClaude Opus 4.6 0e6cc4eef8 feat(audio): Sprint 13 — AudioManager, zone crossfade, dip profiles, murmur wiring (#528, #529, #530, #533)
AudioManager: volume slider persistence (ConfigFile), settings UI with
5 teal-bordered sliders, default_bus_layout.tres for editor. Zone
crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient
tween, auto-activates when server ships OQ-09. Dip profiles: dialogue
dip in show/hide_dialogue, ListeningFocus 30-tick gate via
stationary_ticks in game_state.gd. NPC murmur: client plumbing for
event-driven World SFX playback, no-ops until audio asset arrives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:19:25 +01:00
jpmschweitzerandClaude Opus 4.6 96275c5b90 fix(skills): add agent rules for git and db script usage in sprint-start
Spawned agents are told not to run git commands (team lead handles those)
and to call db/connectors scripts with bare commands only — no python3
prefix or command chaining — so they match the permission whitelist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:10:07 +01:00
jpmschweitzerandClaude Opus 4.6 51abc3e3c1 feat(simulation): #523 add zone_id to ObserverSnapshot (D-077, protocol v11)
Server-tracked zone_id on VisibleTile for D-073 zone crossfade and
D-059 deep fog temperature tint. ZoneMap resource backed by BTreeMap,
observer enrichment in snapshot assembly. Backwards-compatible: v10
payloads deserialize with zone_id: None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:10:06 +01:00
jpmschweitzerandClaude Sonnet 4.6 c2de509c01 feat(simulation): add Sound Lab, Decay Observatory, Shift Change gauntlet rooms (#505)
Three new gauntlet test rooms for Sprint 13, each targeting a specific
system under test. StableIds are additive-only (66–74).

Sound Lab (66-68): Three NPCs at calibrated tile distances (2, 6, 12
tiles) verify D-018 three-range sound model. Tests confirm Close ≤3,
Medium ≤8, Long ≤20 tile thresholds via SoundEvent::audible_at().

Decay Observatory (69): Single NPC observed in LOS then broken. Tests
confirm D-041 confidence decay: KnowsDetails → KnowsOf → Suspects
(floor), and Stale state when stale_after threshold is exceeded.

Shift Change (70-71): Two NPCs with DailyRoutine components. Test
confirms D-031 phase-boundary check_phase_transition issues PathRequest
only to NPCs not already at their target location.

Sprint 13 reset plates (72-74) wired per existing pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:05:46 +01:00
jpmschweitzerandClaude Sonnet 4.6 a394ff6a5f feat(audio): NPC conversation murmur asset (SFX-003)
Produces sfx_npc_murmur.ogg via Stable Audio Open for D-072 universal
event-driven NPC proximity murmur. Organic sonic family (D-074): warm
mid-freq 200-800Hz vocal texture, human voices in adjacent room with
direction and cadence distinguishable, words not. World SFX bus.

Generated 3 candidates (5s/CFG8, 6s/CFG7, 4s/CFG9), selected candidate
1 for richest texture and clearest spatial framing. Unblocks client #533.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:10:17 +01:00
jpmschweitzerandClaude Opus 4.6 61c452aca4 chore(config): whitelist remaining skill invocations
Add sprint-plan, pr-push, pr-review, ticket, docs-search, and
workshop-start to shared permission settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:58 +01:00
jpmschweitzerandClaude Opus 4.6 4eeaf72456 fix(skills): check sprint progress before closing in sprint-start
Case A on main previously closed the active sprint immediately without
checking ticket completion or open PR status. Now checks both and asks
the user before proceeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:56 +01:00
jpmschweitzer 45a644c701 Merge branch 'main' of ssh://git.schweitz.net:2222/jpmschweitzer/settled-reach 2026-02-19 16:54:35 +01:00
jpmschweitzerandClaude Opus 4.6 c13d97b7d9 chore(meta): release v0.1.12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:54:22 +01:00
jpmschweitzerandClaude Opus 4.6 749d53f198 chore(meta): release v0.1.12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:52:07 +01:00
jpmschweitzerandClaude Opus 4.6 bf2ce4ca36 chore(db): backup database after planning merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:51:31 +01:00
jpmschweitzer 72741562e8 Merge remote-tracking branch 'origin/planning'
# Conflicts:
#	CHANGELOG.md
2026-02-19 16:51:20 +01:00
jpmschweitzerandClaude Opus 4.6 6995405950 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:49:57 +01:00
jpmschweitzerandClaude Opus 4.6 03f70ada97 docs(sprints): add Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual
Full audio architecture sprint: AudioManager completion, zone crossfade,
dip profiles, recognition chime, murmur pipeline. Server gauntlet expansion
and OQ-09 resolution. Visual monologue display spec unblocks copy pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:49:39 +01:00
jpmschweitzerandClaude Opus 4.6 5ef845e71d chore(db): backup database after copy merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:46:14 +01:00
jpmschweitzer ce5f735949 Merge remote-tracking branch 'origin/copy' 2026-02-19 16:46:06 +01:00
jpmschweitzerandClaude Opus 4.6 500f15960a chore(meta): update changelog
Sprint 12 copy team output: 26 tickets covering foundation docs,
character builds, design documents, content packs, and ambient
expansion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:44:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 d9facc7958 feat(copy): Sprint 12 #194 — generation pass, ambient variant expansion
80 new ambient/routine lines across all 9 content pack dialogue files.
Expands greeting pools and routine floor/bar talk to reduce cycling in play.

Critical lines untouched: knowledge_grants, threshold lines (real trust),
ring-ops insider dialogue, detective confrontation lines.

Per-file additions:
- shift-supervisor: 7 lines (procedural authority variants)
- dock-worker: 12 lines (floor talk, colleague references, shift texture)
- scheduler: 8 lines (scheduling procedure, audit prep)
- new-hire: 7 lines (orientation questions, colleague curiosity)
- courier: 6 lines (arrival/departure variants)
- bar-owner: 8 lines (greeting variants, house-rule phrases)
- bartender: 9 lines (service register, social observations)
- bar-regular: 10 lines (arrival greetings, community texture)
- ring-operative: 7 lines (surface cover only — route-deflection, maintenance cover)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:42:19 +01:00
jpmschweitzerandClaude Sonnet 4.6 2b62aa275f feat(copy): Sprint 12 Wave 3 — workplace, bar, and ring content packs
#190 Workplace content pack — The Terminal:
- shift-supervisor.yaml: Voss (23 lines, ring-complicit via scheduling cover)
- dock-worker.yaml: generic floor worker (21 lines, dual-lens discrepancy awareness)
- scheduler.yaml: Maret (20 lines, WITNESS/SKEPTIC arc, threshold line at real trust)
- new-hire.yaml: fresh hire (14 lines, inadvertent witness via naive observation)
- courier.yaml: Renn Harsk (16 lines, ring-adjacent documentation handler)

#191 Bar content pack — The Last Shift:
- bar-owner.yaml: Lera (23 lines, Torek tab arrangement, Naia threshold line)
- bartender.yaml: Sess (17 lines, social intelligence, behavioral change tracking)
- bar-regular.yaml: generic regular (20 lines, transition window frustration, Tamm notice)

#192 Smuggling ring content pack — Maintenance Corridors:
- ring-operative.yaml: generic operatives (20 lines, dual-register coded language)
  Coded vocabulary: "cans are ready", "manifest is clean", "Drin was asking",
  "dead air", "the gap". Surface and operational registers sound identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:36:52 +01:00
jpmschweitzerandClaude Sonnet 4.6 266510c3d8 feat(content): populate knowledge fact catalogs for v0.1 transit district
Add machine-readable fact catalog YAML files for all knowledge categories.
Makes the check-fact-ids pre-commit hook validation functional.

New files: awareness.yaml, behavioral.yaml, knowledge.yaml, social.yaml
Populated: investigation.yaml, contraband.yaml, location.yaml,
           relationship.yaml, world.yaml, progress.yaml

50+ canonical fact IDs covering: smuggling ring operations, investigation
findings, behavioral patterns, situational awareness, location facts,
inter-NPC relationships, and social positioning. Character starting
confidence annotated per fact. Derived from knowledge-vocabulary-v01.md (#368).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:22:12 +01:00
jpmschweitzerandClaude Sonnet 4.6 cfbd00f876 docs(decisions): record Gestalt endorsement of D-075 archetype dimension review
D-075 dissent was pending Gestalt and Nigel input on the no-archetype-tag
decision. Gestalt endorses: access tier tags in content handle archetype
distinction emergently; starting knowledge differentials produce per-character
gate timing without hardcoded archetype filters; maintenance cost of archetype
tags would undermine the "two keyholes on the same world" experience (D-027).
Nigel's input still pending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:19:31 +01:00
jpmschweitzerandClaude Sonnet 4.6 e40ce61e6f docs(copy): Sprint 12 Wave 2 — Meridian Coverage Map for Sova Transit (#335)
Ten coverage zones from Gate Terminal Approach (comprehensive) through
Corridor 7B (dead air, Severance-enhanced). Maps detective investigative
progression, smuggler operational geography, NPC behavioral modulation by
zone, and coverage-transition monologue triggers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:19:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 6b1c5e4d29 docs(copy): Sprint 12 Wave 1 — Paula's ten design documents
Character schema and builds:
- #179: Character definition schema (6-section formal spec)
- #180: Smuggler character build (full schema instantiation)
- #181: Detective character build (full schema instantiation)
- #182: Divergent starting knowledge (master comparison table)
- #183: Divergent relationships (NPC web per character)

Institutional design:
- #322: Detective chain of command (Veth, Pres, Tsev backstory)

NPC pattern documents:
- #332: Contradiction arc — reusable FRIEND pattern (5 phases, checklist)
- #329: Mirror moment design — 10 paired dual-lens observations

Experience design:
- #259: First 5 minutes (narrative texture additions to Gestalt's systems doc)
- #307: Flat NPC trait pass (Pael, Ren, Tev — traits, routines, dual-lens)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:18:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 6719cf0fe3 docs(copy): Sprint 12 Wave 1 — Miri's five foundation docs
Cultural generation guide (#189), Sova Texture Appendix (#302),
Contraband specification (#321), Sova Station Profile (#320), and
Span Gate Transit Schedule (#336). These are the blocking foundation
docs for all three content packs (#190/#191/#192) and the Meridian
Coverage Map (#335).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:16:04 +01:00
jpmschweitzerandClaude Sonnet 4.6 7916ebac5f feat(copy): Sprint 12 Wave 1 — flat NPCs, environmental text, insert flavor
Completes three unblocked Sprint 12 copy tickets:

#307 — Flat NPC memorable trait pass (3 NPCs)
- npcs/pael.yaml: maintenance tech, NOBODY/CIVILIAN tier 3
- npcs/ren.yaml: drifter, NOBODY/CIVILIAN tier 3
- npcs/tev.yaml: transit worker (looks like lookout), NOBODY/CIVILIAN tier 3
- dialogue/the-terminal/maintenance-tech.yaml: Pael's 6 lines incl. "seems
  important but isn't" B-7 seal complaint (the-terminal_d_040-045)
- dialogue/the-last-shift/day-worker.yaml: Ren's 6 lines incl. night-rotation
  job-seeking line (the-last-shift_d_001-006)
- dialogue/maintenance-corridors/transit-worker.yaml: Tev's 5 lines incl.
  corridor-watching-but-waiting-for-partner line (maintenance-corridors_d_001-005)

#262 — Environmental text content
- environmental/the-terminal.yaml: 7 items — shift board, manifest terminal
  (4471 discrepancy), maintenance clipboard (Pael's B-7 log), compliance
  notice, Drin/Maret work order note, safety placard, bay status board
- environmental/the-last-shift.yaml: 7 items — drink menu, Lera's rules
  (Torek named), notice board (Vessels Tamm missing), graffiti (bathroom),
  specials board, lattice-free zone notice, cargo workers' association flyer
- environmental/maintenance-corridors.yaml: 6 items — access restriction,
  Meridian scan schedule (14:20-14:45 window documented), maintenance log
  (Pael's anomaly report), routing diagram (pencil annotation), air quality
  notice, C-4 temporary storage log (container 4471 thread)

#331 — Diegetic insert flavor text
- insert/smuggler.yaml: POI labels (first-name, state-dependent), operational
  notifications (Meridian window, drop point, ring check-in, Commission patrol),
  system status strings, examination labels
- insert/detective.yaml: POI labels (surname-first, first-name shift for
  allies is the relationship marker), Commission/case-file notifications,
  field logging strings, examination labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:10:57 +01:00
jpmschweitzerandClaude Opus 4.6 7c0112bd76 chore(db): backup database after client merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:57:06 +01:00
jpmschweitzer 7874493d2a Merge remote-tracking branch 'origin/client' 2026-02-19 15:56:57 +01:00
jpmschweitzerandClaude Opus 4.6 f42c3209d8 chore(db): backup database after worktree merge
Regenerate msgpack fixtures for combined sound_events + rng_seed struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:37:28 +01:00
jpmschweitzerandClaude Opus 4.6 dfcafee3ee fix(client): address PR #43 review — walk key mismatch + 5 suggestions
Fix SOUND_EVENT_ASSETS walk-speed keys to match actual filename
(sfx_footstep_metal_walk), add play_loop null guard, source indicator
colors from Constants, extract CAMERA_DEFAULT_ZOOM, document
consume-once semantics on close_sound_events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:37:20 +01:00
jpmschweitzer 8c92ae2285 Merge remote-tracking branch 'origin/visual' 2026-02-19 15:36:59 +01:00
jpmschweitzer 4bdcc8c3fa Merge remote-tracking branch 'origin/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/Cargo.toml
#	server/src/bridge/text_renderer.rs
#	server/src/bridge/types.rs
#	server/src/perception/observer/mod.rs
#	server/src/simulation/path_follow.rs
#	server/tests/bridge_ipc.rs
#	server/tests/bridge_tcp.rs
#	server/tests/gen_fixtures.rs
#	server/tests/serialization.rs
2026-02-19 15:35:56 +01:00
jpmschweitzerandClaude Opus 4.6 87a90b27bd feat(simulation): wire footstep sound events into movement system
validate_movement now inserts SoundEventEmitter with Footstep events on
every successful move. Intensity scales by stance: Sprint 0.8, Walk 0.5,
Careful 0.3, Crouch 0.15. Range is Close (3 tiles) for all stances.

This completes the sound event pipeline end-to-end: movement produces
events → collect_sound_events drains to queue → observer snapshot
includes audible events → client bridge receives them.

Addresses Tyre critical review item #1 on PR #42.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:14:52 +01:00
jpmschweitzerandClaude Opus 4.6 f533235f58 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:51 +01:00
jpmschweitzerandClaude Opus 4.6 03c44bebaf fix(simulation): address PR #42 review — 8 items from Hoshe and Tyre
1. Sound producers: document empty v0.1 pipeline explicitly (critical)
2. Routine tests: add ActiveSim to 3 tests that passed trivially
3. Rename _observer_pos → observer_pos (used at line 191)
4. Add FactionOnly positive test case (matching faction_id)
5. Fix stale doc comment "Current: 9" → 10 in ObserverSnapshot
6. Remove orphaned SimulationTier/LastInteraction/ScopeTag types
7. Add tracing::warn on FactionOnly non-numeric parse failure
8. Document Medium-range occlusion gap as TODO in audible_at
9. Insert SoundEventQueue in observer test setup_world

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:32 +01:00
jpmschweitzerandClaude Sonnet 4.6 3c4bf742ea docs(visual): Sprint 12 — spatial layouts for three v0.1 locations (#311, #312, #313)
Tile-level floor plans with sightline analysis for all three Sova Transit
District locations. Unblocks copy team content packs #190, #191, #192.

- spatial-layout-terminal-v01.md: The Terminal (44×28m), scanner bays,
  main corridor chokepoint, manifest processing, cargo floor, supervisor
  office with window, restricted storage. Sightline table, access tiers,
  NPC traffic density, Triangle 1 + 2 service notes.
- spatial-layout-bar-v01.md: The Last Shift (28×22m + eastern extension).
  Corner booth sightline analysis (sees entrance, bar, card table, back
  room door simultaneously). Bathroom corridor as private exchange zone.
  Triangle 3 + 4 service notes.
- spatial-layout-smuggling-corridors-v01.md: Ring infrastructure overlay
  — restricted storage staging, maintenance corridor network, three
  dead-drop locations with discovery vectors, transition corridor (40m)
  with camera constraint and traffic density. Full operation flow diagram.

Also carries forward minor visual-grammar and placeholder-spec corrections
from earlier in the sprint (wall_top tile spec, cursor D-056 attribution,
transition strip procedural generation clarification).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 15:07:55 +01:00
jpmschweitzerandClaude Opus 4.6 e047218ad2 fix(ci): remove duplicate doc comment, add rng_seed serialization tests (#344, #527)
Review fixes: deduplicate dump_schedule_graph doc comment,
add rng_seed round-trip test and v9→v10 backward compat test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:05:56 +01:00
jpmschweitzerandClaude Opus 4.6 e1a1a9cfc2 fix(client): address PR review warnings — field guard + public API rename
- main.gd: add has("entity_id") guard to _play_recognition_chimes()
  (matches defensive pattern in _play_close_sound_events and update_sound_events)
- sound_indicator_renderer.gd: rename _color_for_type → color_for_type
  (public testable API, not an internal-only method)
- test_rendering.gd: update test calls to match rename

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:03:10 +01:00
jpmschweitzerandClaude Opus 4.6 f337bb3ca8 fix(client): correct 5 bugs found in pre-PR review
- sound_indicator_renderer: draw_colored_polygon → draw_polygon (runtime crash)
- sound_indicator_renderer: append+dedup instead of clear — indicators now
  survive 3.5s instead of dying after one server tick
- dialogue_box: wire Constants.DIALOGUE_MAX_WIDTH (640px) instead of
  hardcoded 832px MAX_WIDTH_PX
- dialogue_box.tscn: update default offsets to ±320 (was ±416)
- decisions/perception.md: fix footer typo (1920px → 640px)
- tests updated for new append/dedup indicator behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:59:50 +01:00
jpmschweitzerandClaude Opus 4.6 3d306b8a2b feat(ci): add tracing::instrument to heavy per-tick systems, fix stale protocol comment (#344, #527)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:55:00 +01:00
jpmschweitzerandClaude Sonnet 4.6 a71218f6bc feat(ci): tracing JSON format in CI, tick duration logging, schedule dump (#344, #346)
Tracing (#344):
- Add 'json' feature to tracing-subscriber dependency
- Emit JSON log format when CI=true or RUST_LOG_FORMAT=json is set
  (structured log ingestion in CI pipelines)
- Add tracing::debug! with tick_ms/budget_ms/over_budget fields on each
  tick for performance profiling and tier system debugging prerequisite

Schedule dump (#346):
- Add --dump-schedule CLI flag that prints bevy_ecs schedule graph and exits
  without requiring TCP bridge or world setup
- Add make debug-schedule target for CI artifact generation and diff-based
  regression detection of unintended system reordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:46 +01:00
jpmschweitzerandClaude Sonnet 4.6 6a7dc915de feat(bridge): add rng_seed to ObserverSnapshot for deterministic replay (#527)
Adds rng_seed: Option<u64> to ObserverSnapshot. The WRONG button (#507) captures
inputs.jsonl and seed.txt for replay, but seed.txt was writing "unavailable"
because the server did not include the RNG seed in ObserverSnapshot.

Changes:
- bridge/types.rs: PROTOCOL_VERSION 9→10, rng_seed field with serde(default,
  skip_serializing_if = "Option::is_none") for backward compatibility
- perception/observer/mod.rs: inject Res<SimRng> into compute_observer_snapshot,
  populate rng_seed: Some(rng.seed()) each tick
- All test files: add rng_seed: None to ObserverSnapshot constructors
- tests/serialization.rs: bump protocol_version_constant assertion 9→10
- Regenerate msgpack fixtures and golden file for protocol v10

Completes the WRONG button capture loop: replays can now fully reproduce
observed bugs with the exact RNG seed from the capture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:34 +01:00
jpmschweitzerandClaude Sonnet 4.6 3248603838 feat(simulation): ban HashMap via clippy disallowed_types, fix violations (#343)
Adds server/.clippy.toml with disallowed-types for std::collections::HashMap
and std::collections::HashSet. HashMap iteration order is non-deterministic
and breaks deterministic simulation replay (D-030).

Changes:
- server/.clippy.toml: disallow HashMap and HashSet crate-wide
- simulation/movement.rs: WalkabilityMap.chunks and occupied map → BTreeMap;
  add PartialOrd+Ord to ChunkCoord, TilePosition, TilePresence
- simulation/monologue.rs: MonologueState.shown_ids → BTreeSet (simulation state)
- perception/shadowcast.rs: #![allow] — per-frame FOV scratch, iteration irrelevant
- perception/interpretation.rs: #![allow] — per-frame lookup table, key-only access
- perception/query.rs: #![allow] — sector_lookup is a per-frame read-only cache

Also applies cargo fmt to pre-existing format drift in contraband.rs,
dialogue.rs, test_world/mod.rs, and several integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:14:07 +01:00
jpmschweitzerandClaude Sonnet 4.6 8e00db2b2d feat(simulation): #94 #99 active tier filtering and transition system
#94 — Active tier simulation (complete):
- Add ActiveSim marker to all 9 test world room NPC spawns
- Fix test entities in routine.rs and path_follow.rs to include ActiveSim
  so With<ActiveSim> queries match correctly in unit tests

#99 — Tier transition logic (complete):
- Implement update_tier_markers system in tier.rs
- Promotes/demotes tier markers by manhattan distance from PlayerCharacter:
  ≤40 tiles → ActiveSim, ≤120 → BackgroundSim, beyond → StateSaved
- Handles cross-z-level as u32::MAX (effectively unreachable)
- No-op when no PlayerCharacter entity present (headless tests safe)
- 11 new unit tests covering all distance bands and boundary cases
- TierPlugin now registers the system after movement::validate_movement

Also picks up extended test coverage added by hoshe:
- observer/tests.rs — 230 lines of perception observer tests
- sound.rs — additional sound event integration tests

All 548 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:54:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 719721cd4c fix(client): apply arch review corrections — 640px dialogue width, D-067 chime
DIALOGUE_MAX_WIDTH: correct to 640px (20 × TILE_SIZE) per Tyre architecture
  review. D-076 updated in decisions/perception.md with amendment note.
  Initial 1920px was D-061 "max-width" but readability wins at 640px.

D-067 recognition chime: wire sfx_monologue_chime to fog entity recognition
  onset. AudioManager.CHIME_RECOGNITION constant added. main.gd tracks seen
  entity IDs in _known_recognition_ids; fires chime on first appearance in
  pending_recognitions, expires when entity leaves the queue. UISounds bus
  (not WorldSFX) per D-038 "monologue chime is a UI sound." Tests added
  to test_audio_bus_routing.gd (Layer 2b).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:35:27 +01:00
jpmschweitzerandClaude Opus 4.6 9ea9fd2a74 feat(engine): add rng_seed to ObserverSnapshot for deterministic replay (#527)
Add rng_seed: Option<u64> to ObserverSnapshot (protocol v10). Populated
from SimRng state each tick. Completes the WRONG button capture loop —
seed.txt now writes a valid u64 instead of "unavailable", enabling
deterministic replay from bug reports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:34:14 +01:00
jpmschweitzerandClaude Opus 4.6 0f5a990f82 feat(ci): ban HashMap in simulation crate via clippy (#343)
Add clippy::disallowed_types for std::collections::HashMap scoped to
the simulation crate. Replace HashMap with BTreeMap in movement.rs for
deterministic iteration order. Allow exception in perception/query.rs
where iteration order is irrelevant (per-frame scratch buffer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:34:04 +01:00
jpmschweitzerandClaude Opus 4.6 066f8031fd feat(simulation): sprint 12 server — tier system, sound events, KG access, line previewer
Implements 4 completed tickets + partial progress on 2 more:

- #93 Tier marker components (ActiveSim, BackgroundSim, StateSaved + TierPlugin)
- #138 Information tag schema (ObserverAccess enum in knowledge/types.rs)
- #124 Sound event system (SoundEventEmitter, SoundEventQueue, bridge wiring)
- #193 Line previewer CLI (line_preview binary with filter/explain/sequence modes)
- #94 Active tier simulation (in progress — With<ActiveSim> filters)
- #139 Component-level access control (in progress — filter_by_access)

Updates snapshot fixtures and test golden files for new sound_events field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:33:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 c6bd7c2db2 feat(client): Sprint 12 — renderer fix, sound pipeline, medium-range indicators
#345: entity_renderer.gd already used entity_id; added regression tests
  confirming old "id" field is rejected and "entity_id" is accepted.

#447 (OQ-29): DIALOGUE_MAX_WIDTH = 1920 added to constants.gd. Full
  viewport width at target resolution (60 × TILE_SIZE), per D-061 Lead
  directive "max-width". Recorded as D-076 in decisions/perception.md.

#126: SoundIndicatorRenderer — fog-edge directional arrows for medium-range
  sound events (D-018). Node2D at z:951 in World scene. Color-coded per
  D-018/D-069 (neutral/voice/danger). GameState.medium_sound_events
  partitions Medium events from snapshot sound_events field. Tests added
  to test_rendering.gd; Hoshe's test_sound_indicators.gd stubs updated.

#125: Close-range stereo audio pipeline wired. AudioManager.play_sound_event()
  maps event_type to D-038 asset key (Footstep/FootstepSprint → sfx_footstep_*).
  GameState.close_sound_events partitions Close events. main.gd calls
  _play_close_sound_events() each snapshot tick. test_audio_bus_routing.gd
  Layer 4 stubs upgraded to real tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:33:05 +01:00
jpmschweitzerandClaude Sonnet 4.6 aa6541c745 docs(visual): Sprint 12 — visual grammar and placeholder art spec
Delivers #303 (v0.1 Visual Grammar Document) and #252 (Placeholder Art Spec).

#303 — visual-grammar-v01.md
- 7 sections: zone color palettes (hex values, Zone 1/2/3), entity sizing/proportions
  (24x32px art area in 64x64px canvas, dual-scale grid per D-066), entity color system
  (D-033 relationship colors, transition behavior, saturation hierarchy), z-level rendering
  stack (8 layers, y-sort rules, wall rendering, overhead occlusion), typography baseline
  (Michroma at 4 role sizes, dialogue layout per D-061), animation tier baseline (D-047
  Tier 1/Tier 2 with frame budgets), neural insert overlay (D-048 geometric/bloom spec,
  detective vs smuggler variants, fog interaction, insert-off behavior per D-056/D-057 OQ-07).
- Closes #304 (entity color system spec — covered in §3).
- Unblocks #311, #312, #313, #314, #315, #316, #317, #318, #334.

#252 — placeholder-art-spec-v01.md
- Tile dimensions: 64x64px visual tile, 32x32px sim tile, 1080p base resolution.
- Structural tile colors per zone, object tile spec (tinted rectangles + labels).
- Entity sprite spec: 24x32px art in 64x64px canvas, color-via-shader (no baked colors).
- Animation scope: static placeholder in v0.1, production frame budgets documented.
- File format (PNG with alpha), asset directory structure, naming convention.
- Lighting constraints: no baked lighting, Light2D pipeline does all atmosphere.
- Unblocks #133 (placeholder art pipeline).

Cross-references: D-033, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052,
D-056, D-057, D-059, D-061, D-066.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:28:17 +01:00
jpmschweitzerandClaude Opus 4.6 f7e5b7eb63 chore(meta): release v0.1.11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:13:56 +01:00
jpmschweitzerandClaude Opus 4.6 6cd82f1c63 chore(db): backup database after worktree merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:10:43 +01:00
jpmschweitzer f4548d407a Merge remote-tracking branch 'origin/planning' 2026-02-19 13:10:30 +01:00
jpmschweitzer 6ef239fd6e Merge remote-tracking branch 'origin/audio' 2026-02-19 13:10:26 +01:00
jpmschweitzerandClaude Opus 4.6 21b7db9ea5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 b3273599a5 docs(sprints): add Sprint 12 Build briefings
50 tickets across 5 teams: server (tier system, KG boundaries,
sound events), client (audio, fog indicators), copy (all v0.1
content), visual (grammar doc, placeholder spec), ci (tooling).
Sprint goal: production-layer foundations + complete v0.1 copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:30 +01:00
jpmschweitzerandClaude Opus 4.6 84b6e259bc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:52:09 +01:00
jpmschweitzerandClaude Opus 4.6 f0bab3999d chore(skills): rename all skills to domain-action convention
Adopts the whatsinagame naming pattern where the domain comes
first and the action second (e.g. pr-review, sprint-start,
audio-gen). Updated all cross-references in settings, agents,
docs, and inter-skill references.

12 renames: commit→git-commit, create-skill→skill-create,
gen-audio→audio-gen, gen-image→image-gen, plan-sprint→sprint-plan,
push-pr→pr-push, render-sprite→sprite-gen, review-pr→pr-review,
search-docs→docs-search, start-sprint→sprint-start,
start-workshop→workshop-start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:51:56 +01:00
jpmschweitzer 324caab244 Merge remote-tracking branch 'origin/client' 2026-02-19 12:37:28 +01:00
jpmschweitzer 8e879b340a Merge remote-tracking branch 'origin/server' 2026-02-19 12:37:25 +01:00
jpmschweitzerandClaude Opus 4.6 258b266f15 fix(simulation): address PR #39 review — 3 warnings + 6 suggestions
Warnings fixed:
- contraband.rs: scan event now always emits even when NPC already
  knows (was skipped by early `continue`). Contract matches doc.
- test_world/mod.rs: ScanEventBuffer added to player spawn bundle
  so check_contraband_scan doesn't silently no-op in gauntlet mode.
- npc/mod.rs → simulation/mod.rs: moved check_contraband_scan
  registration to SimulationPlugin (operates on player inventory and
  snapshot pipeline, consistent with process_talk_interaction).

Suggestions addressed:
- cross_room_transitions.rs T1: clarified standalone position vs
  constants.rs observer position in comment.
- dialogue.rs: Vec<&str> dedup replaced with BTreeSet<&str> for
  deterministic iteration (project convention).
- contraband.rs: added test for multiple simultaneous ScanAuthority
  NPCs in range (564 tests total).
- dialogue.rs: doc-comment on relationship_to_trust explaining
  KnowledgeConfidence ordering and Suspects default.
- cross_room_transitions.rs T5: noted direct KG API usage vs full
  perception system.
- sprint_gauntlet.rs: documented intentional Contentment { level: 0 }.
- content_scaling.rs: noted GAUNTLET_NPC_COUNT is manually maintained.
- contraband.rs: doc-comment on cross-plugin registration rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:23:02 +01:00
jpmschweitzerandClaude Opus 4.6 44d9941f04 fix(client): address PR #40 review — 13 suggestions
- Rename _insert to insert_state in main.gd (Hoshe #1)
- Add after_each() to test_bug_report_ring_buffer.gd for GameState
  cleanup on assertion failure (Hoshe #2)
- Add after_each() to test_insert_off_behavior.gd for stance/interaction
  restore on assertion failure (Hoshe #3)
- Fix assertion message: "unknown" → "unavailable" (Hoshe #4)
- Document memory ceiling of 60 JSON snapshots in ring buffer (Hoshe #5)
- Add precision warning for u64 rng_seed via JSON float (Hoshe #6)
- Promote _action_enum_to_wire to public action_enum_to_wire (Tyre #1)
- Add @warning_ignore for unused _tick parameter (Tyre #5)
- Document insert_active assumption for future no-insert characters (Tyre #4)
- Restructure OQ-07 decision amendments as bullet points (Tyre #7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:19:41 +01:00
jpmschweitzerandClaude Opus 4.6 590041a1bb chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:46 +01:00
jpmschweitzerandClaude Opus 4.6 f8ae36275d chore(config): add .tmp/ repo directory for agent temp files
Gitignored .tmp/ avoids Bash permission prompts when writing
temp files for tea comment posting. Added sed -n blanket
permission. Updated review-pr skill to use Write tool into
.tmp/ instead of Bash heredocs to /tmp/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:34 +01:00
jpmschweitzerandClaude Opus 4.6 cf8a4922e5 docs(client): sprint 11 test plans and reports
Test plan for #507/#522, preliminary review of #522, and final
combine test report. All tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:52 +01:00
jpmschweitzerandClaude Opus 4.6 2d9bc33760 test(client): add tests for ring buffer and insert-off behavior (#507, #522)
33 tests: 17 for ring buffer (capacity, overwrite, JSONL format, seed),
16 for insert-off behavior (diegetic test, cursor transitions, cross-system).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:47 +01:00
jpmschweitzerandClaude Opus 4.6 c96f1463dc feat(client): WRONG button 60-tick ring buffer captures (#507)
Upgrades bug_report_dialog.gd from single-tick MVP to 60-tick rolling
history. Pre-allocated ring buffers for inputs and snapshots. Outputs
inputs.jsonl (replay-compatible), snapshots.jsonl, and seed.txt on F12.
Inter-frame input accumulation ensures no inputs lost between server ticks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:42 +01:00
jpmschweitzerandClaude Opus 4.6 931399f248 feat(client): resolve OQ-07 — insert-off suppresses verb labels (#522)
Option (a): cursor shape still changes (body orients to targets), but
verb labels and interaction prompts are suppressed when insert_active
is false. Amends D-056 and D-057 with resolution note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:36 +01:00
jpmschweitzerandClaude Sonnet 4.6 bee93963d9 test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)
Cross-room transition tests (server/tests/cross_room_transitions.rs):
- T1: sprint suppresses interaction buffer, restores on Walk (D-055)
- T2: CarriedBy survives room transition — no TilePosition leak (D-065)
- T3: pause mid-corridor discards movement, Unpause resumes (D-031)
- T4: KnowledgeGraph persists across player position change (D-041)
- T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060)
- T6: eavesdrop cut immediately on first movement out of corner (D-071)
- T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070)
- T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071)

All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint).
Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin.

Test suite expansion:
- content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513)
- golden/proof_room_tick_10.json: updated golden file for gauntlet world changes
- golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to
  new world entity count and wire types
- gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:04:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 d103e445e7 feat(simulation): contraband detection + dialogue confidence gate (D-075) (#425, #524)
Contraband detection (Task #425, D-065):
- New module: simulation/contraband.rs — NPC scan checks carried items
  against the KnowledgeGraph confidence gate. NPCs with Authority access
  can initiate a scan; scan outcome depends on item CarriedBy + KG entry.
- Adds ContrabandScanResult event type and ContrabanEntry component.
- Wired into simulation/mod.rs module list.

Dialogue confidence gate (Task #524, D-075 — OQ-18 resolution):
- relationship_to_trust() gains confidence parameter (KnowledgeConfidence).
- Trust tier mapping: (Friendly, KnowsDetails+)→Secret, (Friendly|Known,
  KnowsOf+)→Real, otherwise Surface. Access tier (Layer 1) unchanged.
- Caller process_talk_interaction passes observer KG confidence_of target.
- Resolves OQ-18: confidence co-gates TrustTier, not AccessTier.

Supporting changes:
- decisions/content.md: add D-075 (16 decisions, dated 2026-02-19)
- knowledge/types.rs: expose KnowledgeConfidence comparison helpers
- knowledge/registry.rs: minor API polish
- bridge/types.rs: ContrabandScanResult wire type
- bridge/text_renderer.rs: render contraband scan status
- perception/observer: include carried item count in snapshot
- npc/mod.rs: NPC scan range constant, authority flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:12:24 +01:00
jpmschweitzerandClaude Sonnet 4.6 54754e4080 feat(simulation): add Sprint 11 gauntlet rooms — Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage (#504)
Adds three new test rooms to the gauntlet layout at coordinates that tile
correctly with the existing Sprint 11 world. All rooms follow the canonical
entity-registration pattern (StableId 55-62 assigned in spawn order).

- sprint_gauntlet.rs: 32×22 room (StableId 55-57) — Npc_pacing, Npc_guard,
  Readable sign. Validates sprint suppression (D-055) at close range.
- eavesdrop_alcove.rs: 24×16 room (StableId 58-60) — two NPC speakers,
  corner Readable marker. Validates eavesdrop positioning (D-071).
- confrontation_stage.rs: 32×24 room (StableId 61-62) — Npc_target and
  peripheral passer-by NPC. Validates confrontation verb range (D-070).

Updated test_world: mod.rs registers all new rooms, constants.rs adds GAUNTLET
region constant and room spawn points, reset.rs clears all gauntlet rooms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 6f9a53cb1f docs(briefings): add Sprint 11 Combine briefings
Server (5 tickets), client (2 tickets), and joint coordination
briefings for the Gauntlet completion sprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:54:08 +01:00
jpmschweitzerandClaude Opus 4.6 7282c46db0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:01 +01:00
jpmschweitzerandClaude Opus 4.6 b95e82d17f chore(skills): update gen-audio skill with batch workflow and --post docs
Documents audio-batch manifest format, synth parameters, and the
--post flag as the preferred workflows. Batch reduces approval
count from ~30 to 2 for multi-asset generation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:38 +01:00
jpmschweitzerandClaude Opus 4.6 9f7214dcd6 feat(db): add audio-batch processor and --post flag to audio-generate
Reduces prompt approvals from ~30 per batch to 2 (one manifest write,
one batch run). audio-generate --post chains trim+normalize+convert
after generation. audio-batch processes a JSON manifest of multiple
assets sequentially, supporting both SAO generation and harmonic
synthesis methods. Includes --dry-run, --only, and --skip-existing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:33 +01:00
jpmschweitzerandClaude Opus 4.6 a3a311b3bd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:41 +01:00
jpmschweitzerandClaude Opus 4.6 cfbfa332a2 docs(audio): update asset documentation for Sprint 10 deliverables
Updated ambient.md, sfx.md, and ui.md with generation parameters,
frequency specs, and integration notes for all 8 new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:14 +01:00
jpmschweitzerandClaude Opus 4.6 b86457f257 feat(audio): add 8 minimum viable audio assets (#327)
4 ambient loops (SAO, organic aesthetic, ~43s crossfade loops):
  amb_station_base, amb_workplace_layer, amb_bar_layer, amb_corridor_layer
2 footsteps (SAO hybrid, organic aesthetic, 0.3s):
  sfx_footstep_metal_walk, sfx_footstep_metal_run
2 monologue chimes (manual synthesis, insert-tech aesthetic, 0.8s):
  sfx_monologue_chime, sfx_monologue_chime_urgent

Chimes replace Sprint 9 SAO placeholders with production-quality
manual synthesis per D-074. All assets LUFS -16 normalized.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:07 +01:00
229 changed files with 24983 additions and 516 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ Synthesize findings.
### Qatux (Documenter & Librarian)
- Core team member — participates in discussion rounds as documenter
- Manages document search via `/search-docs` skill
- Manages document search via `/docs-search` skill
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
- Answers "did we discuss this?" with citations
+1 -1
View File
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
- Provide "state of the project" summaries when asked
### Knowledge management
- Maintain the Qdrant document index via /search-docs skill
- Maintain the Qdrant document index via /docs-search skill
- Update briefing files when decisions change
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
- Catch staleness in briefings and flag for update
+9 -2
View File
@@ -50,10 +50,17 @@
"Bash(find *)",
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Skill(commit)",
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(start-sprint)"
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
"Skill(pr-review)",
"Skill(ticket)",
"Skill(docs-search)",
"Skill(workshop-start)"
],
"deny": [
"Bash(git push --force *)",
+255
View File
@@ -0,0 +1,255 @@
---
name: audio-gen
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate a single asset (WAV only)
db/connectors/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/output.wav
# Generate + post-process in one command (WAV → trim → normalize → OGG)
db/connectors/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/gen/intermediate.wav \
--output-ogg client/assets/audio/final.ogg
# Batch-generate from a manifest (preferred for multiple assets)
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
| `--post` | off | — | Run trim + normalize + convert after generation. |
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Batch Workflow (Preferred)
For generating multiple assets, use a manifest file. This reduces prompt
approvals to 2: one Write (manifest) + one Bash (batch run).
### 1. Create the manifest
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
```json
{
"description": "Sprint 10 ambient + world SFX batch",
"output_dir": "client/assets/audio",
"gen_dir": "client/assets/audio/gen",
"defaults": {
"steps": 100,
"cfg": 7,
"lufs": -16,
"quality": 6
},
"assets": [
{
"id": "AMB-001",
"filename": "amb_station_base.ogg",
"method": "sao",
"duration": 45,
"steps": 150,
"cfg": 5,
"prompt": "[sonic family prefix] + [template] + [description]"
},
{
"id": "UI-005",
"filename": "sfx_monologue_chime.ogg",
"method": "synth",
"synth": {
"type": "harmonic",
"duration": 0.8,
"fundamental": 1200,
"harmonics": [
{"freq": 2400, "db": -12},
{"freq": 3600, "db": -24}
],
"attack_ms": 15,
"sustain_ratio": 0.2,
"decay": "exponential"
}
}
]
}
```
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
### 2. Run the batch
```bash
# Full run
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
# Dry run — preview what would be generated
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
# Generate only specific assets
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
# Skip assets that already have OGG files
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
```
### 3. Update asset docs with prompts
After the batch completes, write the exact prompts used back into the
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
what was generated; the asset docs record what we have.
### Manifest fields
| Field | Required | Notes |
|-------|----------|-------|
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
| `filename` | yes | Output filename (must match asset doc) |
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
| `duration` | SAO only | Duration in seconds |
| `prompt` | SAO only | Full assembled prompt |
| `steps` | no | Override default steps |
| `cfg` | no | Override default CFG |
| `synth` | synth only | Synthesis parameters (see below) |
### Synth parameters
| Field | Default | Notes |
|-------|---------|-------|
| `type` | harmonic | Only `harmonic` supported currently |
| `duration` | — | Duration in seconds |
| `fundamental` | — | Fundamental frequency in Hz |
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
| `attack_ms` | 10 | Attack time in milliseconds |
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
| `decay` | exponential | `exponential` or `linear` |
## Single Asset Workflow
For one-off generation or iteration on a specific asset:
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
2. Read `references/sonic-palette.md` for the sonic family prefix.
3. Read `references/category-templates.md` for the matching template.
4. Assemble the full prompt.
5. Run `db/connectors/audio-health` to verify the API is up.
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
generate and post-process in one step.
7. Verify the output (file size, duration).
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate (post-processing is already done if `--post` was
used).
## Post-Processing (Standalone)
If you need to post-process separately (e.g., re-normalizing an existing file):
```bash
# Full pipeline: trim → normalize → convert
db/connectors/audio-post pipeline input.wav --output output.ogg
# Individual steps
db/connectors/audio-post trim input.wav
db/connectors/audio-post normalize input.wav --lufs -16
db/connectors/audio-post convert input.wav --output output.ogg
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
or the batch manifest's `method: "synth"` with harmonic parameters.
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
impulse), write a custom script in `tooling/` following the pattern in
`tooling/synth_ui_sounds.py`.
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
AudioManager discovers assets by directory scan — filenames must match exactly.
@@ -1,9 +1,9 @@
---
name: search-docs
name: docs-search
description: >
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
-187
View File
@@ -1,187 +0,0 @@
---
name: gen-audio
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate audio
db/connectors/audio-generate "prompt text" \
--duration 10 \
--steps 100 \
--cfg 7 \
--output path/to/output.wav \
--timeout 600
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output file path. Auto-names from prompt if omitted. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
- **Output is WAV at 44.1kHz stereo.** Convert to .ogg for Godot import:
`ffmpeg -i input.wav -c:a libvorbis -q:a 6 output.ogg`
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Single Asset Workflow
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
2. Read `references/sonic-palette.md` for the sonic family prefix.
3. Read `references/category-templates.md` for the matching template.
4. Assemble the full prompt.
5. Run `db/connectors/audio-health` to verify the API is up.
6. Run `db/connectors/audio-generate` with the assembled prompt. **One request
at a time. Wait for completion.**
7. Listen to the output (or describe it based on file size/duration).
8. If acceptable, convert to .ogg and place in `client/assets/audio/`.
9. Update the asset status in `docs/assets/audio/{category}.md`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate, trim, normalize, convert.
## Post-Processing
After selecting the best generation:
```bash
# Trim silence from start/end
ffmpeg -i input.wav -af "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse" trimmed.wav
# LUFS normalize to -16 LUFS (broadcast standard, good for game audio)
ffmpeg -i trimmed.wav -af loudnorm=I=-16:LRA=11:TP=-1 normalized.wav
# Convert to .ogg for Godot
ffmpeg -i normalized.wav -c:a libvorbis -q:a 6 output.ogg
# For loops: verify loop point
ffplay -loop 0 output.ogg
```
For ambient loops, create crossfade overlap:
```bash
# Create a 45s loop with 3s crossfade overlap
# (manual: export 48s, crossfade first 3s with last 3s in Audacity)
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis instead:
```python
# Example: 50ms cursor hover tick
import numpy as np
import wave
sr = 44100
duration = 0.05 # 50ms
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
freq = 3200 # Hz
signal = np.sin(2 * np.pi * freq * t)
envelope = np.exp(-t * 80) # exponential decay
audio = (signal * envelope * 32767).astype(np.int16)
with wave.open("cursor_hover.wav", "w") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(sr)
f.writeframes(audio.tobytes())
```
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/palette.md)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs:
```
client/assets/audio/
amb_station_base.ogg # Ambient bus
amb_workplace_layer.ogg # Ambient bus
amb_bar_layer.ogg # Ambient bus
amb_corridor_layer.ogg # Ambient bus
sfx_footstep_metal.ogg # Player Actions bus
sfx_footstep_metal_run.ogg # Player Actions bus
cursor_hover.ogg # UI Sounds bus
implant_open.ogg # UI Sounds bus
fog_recognition.ogg # UI Sounds bus
weapon_aim.ogg # UI Sounds bus
sfx_monologue_chime.ogg # UI Sounds bus
sfx_monologue_chime_urgent.ogg # UI Sounds bus
```
AudioManager discovers these by directory scan — filenames must match exactly.
@@ -1,8 +1,8 @@
---
name: commit
name: git-commit
description: >
Commit changes with clean, structured messages. Use when the user says
"commit", "save my work", "commit changes", or invokes /commit. Enforces
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
conventional commit format, groups changes into logical commits, and maintains
CHANGELOG.md. Never squash unrelated changes into one commit.
user-invocable: true
@@ -1,5 +1,5 @@
---
name: gen-image
name: image-gen
description: >
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
@@ -1,9 +1,9 @@
---
name: push-pr
name: pr-push
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
@@ -41,7 +41,7 @@ git status
```
If there are uncommitted changes (staged or unstaged), run the **commit skill**
first. Use the `/commit` skill to group changes into logical commits with
first. Use the `/git-commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
proceeding.
@@ -136,5 +136,5 @@ already `done`, `review`, `cancelled`, or `backlog` (only transition
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
PR title instead of generating one.
@@ -1,9 +1,9 @@
---
name: review-pr
name: pr-review
description: >
Review a branch diff with team-appropriate agents before merge. Use when the
user says "review-pr", "review this PR", "review this branch", or invokes
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
audio) in parallel. Reports approve/reject with inline comments.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Task
@@ -156,19 +156,16 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
```bash
# Step 1: Write review to temp file (use Bash heredoc, NOT the Write tool)
cat > /tmp/review-<branch>.md << 'EOF'
## Review: <branch> -> main
...review content...
EOF
```
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
# Step 2: Post to Gitea (separate Bash call)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
```
These MUST be separate sequential Bash calls — do not chain them in a single
command. The heredoc write must complete before `tea comment` reads the file.
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
directory is gitignored and exists in the repo root for this purpose.
## 7. Merging approved PRs
@@ -1,5 +1,5 @@
---
name: create-skill
name: skill-create
description: >
Guidance for creating effective Claude Code skills (.skill packages).
Use when the user wants to create, build, design, or iterate on a skill —
@@ -1,9 +1,9 @@
---
name: plan-sprint
name: sprint-plan
description: >
Plan the next sprint and generate team briefing files. Use when the user says
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
proposes ticket selection, and writes briefing files per team to
docs/sprints/sprint-N/.
user-invocable: true
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
```
Task(
subagent_type: "si",
prompt: "Run /plan-sprint for Sprint N. Read the skill at
.claude/skills/plan-sprint/SKILL.md for the full workflow,
prompt: "Run /sprint-plan for Sprint N. Read the skill at
.claude/skills/sprint-plan/SKILL.md for the full workflow,
then execute it. Use the arguments provided: {args}",
description: "Plan sprint N"
)
@@ -1,9 +1,9 @@
---
name: start-sprint
name: sprint-start
description: >
Manage the sprint lifecycle from main, or start sprint work on a team
branch. Use when the user says "start sprint", "start working on the
server/client/copy", "begin sprint", or invokes /start-sprint. On main:
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
@@ -34,7 +34,7 @@ the team branch workflow (steps 28).
## Main branch workflow (sprint lifecycle management)
When `/start-sprint` is run on `main`, assess the current sprint state
When `/sprint-start` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
@@ -45,7 +45,38 @@ Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
First, check whether the sprint's work is actually done:
```bash
db/connectors/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
Also check for open PRs that may contain completed work waiting for
review or merge:
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Report the full picture to the user:
- Ticket progress (done / in_progress / backlog counts)
- Open PRs (if any — these represent work that's done but not merged)
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
exist**, do NOT close the sprint. Instead, report the current progress
and ask the user what they want to do:
- **Continue working** — switch to a team branch and run `/sprint-start`
there to resume work
- **Review & merge PRs first** — (only if open PRs exist) merge pending
work before deciding whether to close
- **Close anyway** — proceed with the close workflow below (carries over
unfinished tickets)
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
explicitly chooses to close.
#### A1. Close the active sprint
@@ -97,7 +128,7 @@ git push && git push --tags
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/plan-sprint`.
running `/sprint-plan`.
---
@@ -115,7 +146,7 @@ A sprint is ready to activate. Verify it looks complete:
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/plan-sprint` to complete planning.
and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
@@ -125,14 +156,14 @@ db/connectors/sprint start
Then report:
- Sprint activated (name, ticket count per team)
- Remind the user to switch to a team branch and run `/start-sprint`
- Remind the user to switch to a team branch and run `/sprint-start`
there (or `cd` into the relevant worktree)
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/plan-sprint`
Nothing is ready. Report the state and suggest running `/sprint-plan`
to plan the next sprint.
---
@@ -167,7 +198,7 @@ If no active sprint is found, report that and stop.
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/plan-sprint` to generate one.
`/sprint-plan` to generate one.
### 5. Load ticket details
@@ -257,6 +288,17 @@ 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.
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.
@@ -1,11 +1,11 @@
---
name: render-sprite
name: sprite-gen
description: >
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
east, south, west) with outline applied at working resolution. Use when the
user says "render sprite", "render model", "run the render pipeline",
"test the pipeline", "/render-sprite", or asks to render a specific model
"test the pipeline", "/sprite-gen", or asks to render a specific model
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
---
@@ -14,7 +14,7 @@ description: >
Run the render script with the model name:
```bash
.claude/skills/render-sprite/scripts/render.sh <model_name>
.claude/skills/sprite-gen/scripts/render.sh <model_name>
```
### Available Models
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
1. Create model scene at `renderer/models/<name>.tscn`
2. Apply texture from `renderer/textures/` via StandardMaterial3D
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
@@ -1,8 +1,8 @@
---
name: start-workshop
name: workshop-start
description: >
Start a multi-agent design workshop from a workshop brief. Use when the user says
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
Parses the workshop brief to extract participants, questions, and round format.
Creates a team, tasks, and spawns agents as teammates via the Task tool.
---
+3 -5
View File
@@ -73,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
Show a summary table of branches ahead of main. For each branch, indicate:
- `[PR]` if it has an open pull request — warn that it should go through
normal review channels (use `/review-pr` instead)
normal review channels (use `/pr-review` instead)
- Commit count and summary
Use `AskUserQuestion` to let the user pick which branches to merge.
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
If clean, report the result (fast-forward or merge commit, files changed).
If conflicts, report them and stop.
### 3. Push prompt
### 3. Push
After a successful merge, ask the user if they want to push:
After a successful merge, push the branch:
```bash
git push origin <current-branch>
```
Never push without explicit confirmation.
+1
View File
@@ -1,5 +1,6 @@
# Build and cache
.cache/
.tmp/
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
+83
View File
@@ -6,6 +6,84 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.13] — 2026-02-20
### Added
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
## [v0.1.12] — 2026-02-19
### Added
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
- Information tag schema (#138), component-level access control (#139)
- Line previewer CLI (#193)
- Sound event system — server pipeline (#124)
- Close-range stereo audio — client positional 2D (#125)
- Medium-range visual indicators — fog-edge directional arrows (#126)
- HashMap ban in simulation crate via clippy (#343)
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
- v0.1 Visual Grammar Document (#303)
- Placeholder art specification (#252)
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
- Sova Station Profile — 6 districts, governance, off-station references (#320)
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
- Divergent starting knowledge and relationships per character (#182, #183)
- Detective institutional chain of command (#322)
- Contradiction arc design document — reusable FRIEND pattern (#332)
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
- First 5 minutes experience design — systemic opening per character (#259)
- Opening hook content per character (#260)
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
- Knowledge state vocabulary — author-facing quick reference (#309)
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
- Diegetic insert flavor text — per-character labels and notification strings (#331)
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
### Fixed
- Entity renderer field name bug — `id` vs `entity_id` (#345)
- Dialogue max-width pixel value — 640px per D-076 (#447)
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
- Stale protocol version doc comment "Current: 9" corrected to 10
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
### Changed
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
- Observer test setup now inserts SoundEventQueue resource for integration coverage
- Added FactionOnly positive test case and Medium-range occlusion TODO
- Sound indicator colors sourced from Constants instead of duplicated hex literals
- play_loop() null guard on stream.duplicate()
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
## [v0.1.11] — 2026-02-19
### Added
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
- `sed -n` blanket permission in shared settings
### Changed
- All skills renamed to domain-action convention (e.g. `commit``git-commit`, `review-pr``pr-review`, `gen-audio``audio-gen`, `render-sprite``sprite-gen`) — 12 renames total
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
## [v0.1.10] — 2026-02-19
### Added
@@ -17,12 +95,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
### Changed
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
- World radial menu uses theme font instead of ThemeDB.fallback_font
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
### Fixed
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
+7 -1
View File
@@ -35,7 +35,7 @@ db/
decisions/ # Decision domain files (source of truth)
README.md # Domain index and query examples
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
process.md # D-004, D-021, D-022
@@ -132,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
Key rules:
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
- **Use `--output simple`** for machine-readable output (no table borders)
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
```bash
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
# Step 2: Post via cat
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
```
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
+8 -1
View File
@@ -7,7 +7,7 @@ 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
perf-baseline debug-schedule
# --- Configuration ---
@@ -53,6 +53,7 @@ help:
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
@echo ""
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -285,6 +286,12 @@ checklist-generate:
perf-baseline:
@tooling/perf-baseline
# --- Schedule debug (#346) ---
debug-schedule:
@echo "Dumping bevy_ecs schedule graph..."
@cd server && cargo run -- --dump-schedule
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://caeuztw9ah5h5l"
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_npc_murmur.ogg"
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+43
View File
@@ -0,0 +1,43 @@
[gd_resource type="AudioBusLayout" format=3]
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
[resource]
bus/0/name = "Master"
bus/0/solo = false
bus/0/mute = false
bus/0/bypass_fx = false
bus/0/volume_db = 0.0
bus/0/send = &""
bus/1/name = "Music"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
bus/2/name = "Ambient"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = &"Master"
bus/3/name = "WorldSFX"
bus/3/solo = false
bus/3/mute = false
bus/3/bypass_fx = false
bus/3/volume_db = 0.0
bus/3/send = &"Master"
bus/4/name = "PlayerActions"
bus/4/solo = false
bus/4/mute = false
bus/4/bypass_fx = false
bus/4/volume_db = 0.0
bus/4/send = &"Master"
bus/5/name = "UISounds"
bus/5/solo = false
bus/5/mute = false
bus/5/bypass_fx = false
bus/5/volume_db = 0.0
bus/5/send = &"Master"
+4
View File
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[audio]
buses/default_bus_layout="res://default_bus_layout.tres"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
+13 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=22 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"]
@@ -16,9 +16,11 @@
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -96,6 +98,13 @@ script = ExtResource("4_fog")
z_index = 950
script = ExtResource("16_fogent")
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
; Directional arrows at fog boundary for sounds outside LOS.
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
[node name="SoundIndicators" type="Node2D" parent="World"]
z_index = 951
script = ExtResource("20_soundind")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
@@ -154,3 +163,6 @@ layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
+129 -5
View File
@@ -5,6 +5,11 @@ extends Node
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
# --- D-067: Recognition chime asset key ---
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
const CHIME_RECOGNITION := "sfx_monologue_chime"
# --- Bus names (D-068) ---
const BUS_MUSIC := "Music"
const BUS_AMBIENT := "Ambient"
@@ -36,6 +41,23 @@ const DIP_SPECS := {
# Low-pass filter default cutoff — effectively bypassed at this value.
const FILTER_CUTOFF_DEFAULT := 20500.0
# --- D-073: Zone crossfade constants ---
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
# they are the same location type, so hub→workplace transition is a same-asset no-op
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
const ZONE_ASSETS: Dictionary = {
"hub": "amb_hub_layer",
"workplace": "amb_hub_layer",
"bar": "amb_bar_layer",
"corridor": "amb_corridor_layer",
}
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
@@ -52,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
var _ambient_players: Dictionary = {}
# D-073: Zone crossfade state
var _current_zone_id: String = ""
var _zone_tweens: Array = []
signal dip_changed(profile: String)
const PREFS_PATH := "user://audio_prefs.cfg"
func _ready() -> void:
_setup_buses()
_scan_registry()
_load_prefs()
# --- Bus setup ---
@@ -105,6 +134,7 @@ func _scan_dir(path: String) -> void:
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
dir.list_dir_end()
func has_asset(asset_key: String) -> bool:
@@ -137,6 +167,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye
return null
stop_loop(asset_key)
var loop_stream := stream.duplicate() as AudioStream
if loop_stream == null:
return null
_enable_loop(loop_stream)
var player := AudioStreamPlayer.new()
player.stream = loop_stream
@@ -163,6 +195,32 @@ func stop_all_loops() -> void:
stop_loop(key)
# --- Audio asset registry: event type → asset key (D-018, #125) ---
# Maps server-sent sound event_type strings to audio asset keys.
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = {
"Footstep": "sfx_footstep_metal_walk",
"FootstepWalk": "sfx_footstep_metal_walk",
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepSprint": "sfx_footstep_metal_run",
"FootstepRun": "sfx_footstep_metal_run",
}
## Play a close-range sound event at a world tile position (D-018, #125).
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
## world_tile_pos: server tile coordinates — converted to world pixels internally.
## No-ops if event_type has no registered asset or asset file is absent.
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
if asset_key.is_empty():
return
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
# --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels).
@@ -270,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
var buses: Dictionary = spec.get("buses", {})
var offset_db: float = buses.get(bus, 0.0)
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
_save_prefs()
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Zone crossfade (D-073 stub) ---
# --- Volume persistence (user://audio_prefs.cfg) ---
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
for bus_name in BUSES:
if cfg.has_section_key("audio", bus_name):
var db: float = cfg.get_value("audio", bus_name, 0.0)
# Apply directly: bypass _save_prefs() on initial load.
_bus_volumes[bus_name] = db
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
for bus_name in BUSES:
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
# --- Zone crossfade (D-073) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Full crossfade implementation deferred to Sprint 9+ (D-073).
## Stub exists so server integration can call it without conditional checks.
func set_zone(_zone_id: String) -> void:
pass
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
## Interruptible — mid-crossfade zone change tweens from current position.
## No-op if assets absent (D-038) or same zone.
func set_zone(zone_id: String) -> void:
if zone_id == _current_zone_id:
return
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
_current_zone_id = zone_id
_kill_zone_tweens()
# Fade out old ambient layer (if different asset from incoming zone)
if not old_asset.is_empty() and old_asset != new_asset:
if _ambient_players.has(old_asset):
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
if is_instance_valid(old_player):
var tween := create_tween()
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
tween.tween_callback(stop_loop.bind(old_asset))
_zone_tweens.append(tween)
# Fade in new ambient layer
if not new_asset.is_empty():
if _ambient_players.has(new_asset):
# Already playing (interrupted reverse crossfade) — tween from current volume
var existing: AudioStreamPlayer = _ambient_players[new_asset]
if is_instance_valid(existing):
var tween := create_tween()
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
elif has_asset(new_asset):
var new_player := play_loop(new_asset, BUS_AMBIENT)
if new_player:
new_player.volume_db = -80.0
var tween := create_tween()
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
func _kill_zone_tweens() -> void:
for tween in _zone_tweens:
if tween != null and tween.is_valid():
tween.kill()
_zone_tweens.clear()
# --- Internal helpers ---
+81
View File
@@ -40,9 +40,39 @@ var dialogue_active: bool = false
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
# Cursor shape changes still fire when false (D-056 option a).
# v0.1 assumption: always true — both playable characters (detective and smuggler)
# have neural inserts. Future characters without inserts would receive false from
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
var insert_active: bool = true
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
# Null in v0.1 (server does not yet send this field; protocol change required).
var rng_seed: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
# #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 = []
# #125, D-018: Close-range sound events for positional 2D audio.
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
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.
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
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).
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -64,6 +94,14 @@ 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
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
if snapshot.has("tiles"):
visible_tiles = snapshot.tiles
@@ -132,6 +170,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
room_id = null
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
# Server may send false for characters without an insert in future sprints.
if snapshot.has("insert_active") and snapshot.insert_active is bool:
insert_active = snapshot.insert_active
else:
insert_active = true
# #507: rng_seed — server sends current RNG seed for replay determinism.
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
if snapshot.has("rng_seed"):
rng_seed = snapshot.rng_seed
else:
rng_seed = null
# D-018: Sound events from server — partition by range_category.
# #126: Medium → fog-edge directional indicators.
# #125: Close → positional 2D audio via AudioManager.
if snapshot.has("sound_events") and snapshot.sound_events is Array:
medium_sound_events = []
close_sound_events = []
for se in snapshot.sound_events:
if not se is Dictionary:
continue
var rc: String = se.get("range_category", "")
if rc == "Medium":
medium_sound_events.append(se)
elif rc == "Close":
close_sound_events.append(se)
else:
medium_sound_events = []
close_sound_events = []
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
# positional indexing — use the same tile iteration below instead.
current_zone_id = ""
var _px := int(player_position.x)
var _py := int(player_position.y)
for _ztile in visible_tiles:
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
current_zone_id = _ztile.get("zone_id", "")
break
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+4 -4
View File
@@ -179,7 +179,7 @@ func send_input(player_input: Dictionary) -> Error:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
@@ -192,9 +192,9 @@ func send_input(player_input: Dictionary) -> Error:
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
# _action_enum_to_wire already emits push_warning for invalid actions
# 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.
@@ -251,7 +251,7 @@ func drain_outbound() -> Array[Dictionary]:
# 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:
static func action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
+9
View File
@@ -90,6 +90,15 @@ const PERIPHERAL_ALPHA: float = 0.5
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
# Tyre architecture review 2026-02-19: readability over max-width; fits
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 640
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
+112
View File
@@ -15,13 +15,19 @@ 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 bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
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
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -81,6 +87,16 @@ func _process(_delta: float) -> void:
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"):
@@ -102,6 +118,9 @@ func _process(_delta: float) -> void:
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()
@@ -110,6 +129,16 @@ func _process(_delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_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()
@@ -135,6 +164,8 @@ func _process(_delta: float) -> void:
camera.reset_smoothing()
# Send queued input to simulation
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
@@ -142,6 +173,14 @@ func _process(_delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
if settings_dialog.is_open():
settings_dialog.close()
else:
settings_dialog.open()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -164,6 +203,79 @@ func _process(_delta: float) -> void:
"verb": null,
}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
# then clears the accumulator for the next tick.
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
bug_report_dialog.record_tick(
GameState.current_tick,
JSON.stringify(GameState.current_snapshot),
_pending_record_inputs
)
_pending_record_inputs.clear()
# 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).
# Consume-once: events are cleared after processing so they don't replay if
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
func _play_close_sound_events() -> void:
for evt in GameState.close_sound_events:
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
continue
AudioManager.play_sound_event(
evt.get("event_type", ""),
Vector2(float(evt.x), float(evt.y))
)
GameState.close_sound_events = []
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
# enters the cognitive delay recognition queue for the first time.
# "The chime marks the character's attention shifting" (D-067).
# Entities that complete recognition (leave pending_recognitions) are removed
# from _known_recognition_ids so they can chime again if re-encountered.
func _play_recognition_chimes() -> void:
var active_ids: Dictionary = {}
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
active_ids[eid] = true
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# Expire IDs no longer in the recognition queue
for eid in _known_recognition_ids.keys():
if not active_ids.has(eid):
_known_recognition_ids.erase(eid)
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
# (extracted in apply_snapshot(), server-authoritative per D-020).
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
func _update_zone() -> void:
var zone := GameState.current_zone_id
if zone != _current_zone:
_current_zone = zone
AudioManager.set_zone(zone)
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
# Only activates when no other dip (dialogue/confrontation) is running.
# Only deactivates its own dip — never touches dialogue/confrontation.
# D-070: no UI indicator — the boost is "felt, not computed."
func _update_listening_focus() -> void:
var current_dip := AudioManager.get_active_dip()
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
if threshold_met and current_dip == "":
AudioManager.apply_dip("listening_focus")
elif not threshold_met and current_dip == "listening_focus":
AudioManager.clear_dip()
# Consume-once per tick: show monologue text, then clear.
@@ -11,6 +11,9 @@ enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
var current_state: State = State.DEFAULT
var hovered_entity_id: int = -1
var weapon_mode_active: bool = false
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
# Cursor shape transitions still fire — the character's body still orients to targets.
var insert_active: bool = true
signal state_changed(new_state: State)
signal hovered_entity_changed(entity_id: int)
@@ -305,8 +308,15 @@ func get_z_layer() -> int:
func should_show_interactions() -> bool:
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
if not insert_active:
return false
return not weapon_mode_active or _shift_held
func set_insert_active(active: bool) -> void:
insert_active = active
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles
@@ -0,0 +1,152 @@
class_name SoundIndicatorRenderer
extends Node2D
## Medium-range sound indicators (#126, D-018).
## Renders directional arrows at the fog boundary for sounds outside LOS.
##
## Close-range sounds → 2D positional audio (handled by AudioManager).
## Medium-range sounds → visual arrow at fog edge pointing toward source.
##
## Color per D-018/D-069:
## Neutral #c8d0e0 — footsteps, generic sounds
## Voice #e8c547 — speech, conversation, social sounds
## Danger #d45d5d — alert, gunshot, explosion, threat
##
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
## the last FADE_DURATION seconds. New events are appended each tick;
## expired indicators are removed by _process(). Deduplication prevents
## the same source position from stacking multiple arrows.
const TILE_SIZE: int = Constants.TILE_SIZE
# Visual parameters
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
const FADE_DURATION: float = 0.6 # Fade-out window at end
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
const ARROW_LEN: float = 12.0 # Length from tip to base
# D-018/D-069 colors — sourced from Constants to prevent palette drift
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
# Indicators: [{x, y, event_type, elapsed}]
var _indicators: Array = []
func _process(delta: float) -> void:
if _indicators.is_empty():
return
var i := _indicators.size() - 1
while i >= 0:
_indicators[i].elapsed += delta
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
_indicators.remove_at(i)
i -= 1
queue_redraw()
## Append new medium-range sound events from snapshot.
## events: Array of {x: float, y: float, event_type: String}
## Only Medium range_category events should be passed.
## Deduplicates by tile position — if an indicator already exists at (x,y),
## its timer resets instead of spawning a duplicate arrow.
func update_sound_events(events: Array) -> void:
for evt in events:
if not evt.has("x") or not evt.has("y"):
continue
var ex: float = float(evt.x)
var ey: float = float(evt.y)
# Deduplicate: reset timer if an indicator already exists at this tile
var found := false
for ind in _indicators:
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
ind.elapsed = 0.0
ind.event_type = evt.get("event_type", "")
found = true
break
if not found:
_indicators.append({
"x": ex,
"y": ey,
"event_type": evt.get("event_type", ""),
"elapsed": 0.0,
})
if not _indicators.is_empty():
queue_redraw()
func _draw() -> void:
if _indicators.is_empty():
return
var player_world: Vector2 = GameState.player_position * TILE_SIZE
# Compute half-extents of the visible world area from camera zoom + viewport.
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
# the fog edge and don't clip to the physical screen border.
var vp_size := get_viewport().get_visible_rect().size
var cam := get_viewport().get_camera_2d()
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
var half_extents: Vector2 = vp_size / (2.0 * zoom)
for ind in _indicators:
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
var dir: Vector2 = sound_world - player_world
if dir.is_zero_approx():
continue
dir = dir.normalized()
# Project direction onto the visible-area rectangle boundary
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
var t: float = ind.elapsed / INDICATOR_LIFETIME
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
var alpha: float
if t < fade_start:
alpha = 1.0
else:
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
var color: Color = color_for_type(ind.event_type)
color.a = alpha * 0.9
_draw_arrow(edge_pt, dir, color)
## Project from center along dir to the boundary of a rectangle with
## half_extents, inset by inset pixels. Returns the boundary point.
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
var h := Vector2(
maxf(half.x - inset, 8.0),
maxf(half.y - inset, 8.0),
)
# Ray-AABB slab test: find smallest positive t where the ray exits
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
var t: float = minf(t_x, t_y)
return center + dir * t
## Draw a filled arrowhead at pos pointing in dir.
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := pos - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
## Map event type string → D-018 color category.
func color_for_type(event_type: String) -> Color:
var et := event_type.to_lower()
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
return COLOR_VOICE
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
or et.contains("alert") or et.contains("threat"):
return COLOR_DANGER
return COLOR_NEUTRAL
@@ -16,6 +16,7 @@ extends Node2D
@onready var tile_renderer = $FogGroup/FloorTiles
@onready var fog_renderer = $FogOverlay
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
var _last_tick: int = -1
@@ -42,3 +43,7 @@ func update_from_state() -> void:
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
entity_renderer.update_entities(GameState.visible_entities)
# D-018 #126: Update medium-range sound indicators
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
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.
+1 -1
View File
@@ -518,7 +518,7 @@ func test_bug_report_not_double_activatable() -> void:
func test_bug_report_action_not_on_wire() -> void:
# BUG_REPORT is client-only — must not produce a wire-format action name.
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.BUG_REPORT)
assert_that(wire_name).override_failure_message(
"BUG_REPORT must not produce a wire action name (client-only)"
).is_equal("")
+357
View File
@@ -0,0 +1,357 @@
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
## Spec refs:
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
## D-069: Audio dip profiles — confrontation drops WorldSFX 46dB
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
##
## Test layers:
## 1. D-068 Bus architecture constants (no implementation required)
## 2. D-069 Dip profile spec values (no implementation required)
## 3. AudioManager API — dip state machine, signal emission
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
class_name TestAudioBusRouting
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
func after_test() -> void:
AudioManager.clear_dip()
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
# ==============================================================================
# Layer 1: D-068 Bus Architecture Constants
# ==============================================================================
func test_d068_bus_world_sfx_name() -> void:
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d068_bus_ambient_name() -> void:
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
func test_d068_bus_player_actions_name() -> void:
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
func test_d068_bus_ui_sounds_name() -> void:
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
func test_d068_bus_music_name() -> void:
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
func test_d068_five_buses_defined() -> void:
## D-068: Exactly 5 buses in the architecture.
assert_that(AudioManager.BUSES.size()).is_equal(5)
func test_d068_all_bus_names_present_in_buses_array() -> void:
## D-068: BUSES array must contain all 5 named buses.
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
func test_d068_bus_names_are_unique() -> void:
## D-068: No two buses share a name.
var seen: Dictionary = {}
for bus_name in AudioManager.BUSES:
assert_that(seen.has(bus_name)).is_false()
seen[bus_name] = true
# ==============================================================================
# Layer 2: D-069 Dip Profile Spec Values
# Close-range sound events go on WorldSFX; confrontation dips that bus 46 dB.
# ==============================================================================
func test_d069_three_dip_profiles_exist() -> void:
## D-069: Three profiles — dialogue, confrontation, listening_focus.
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
## D-069: Confrontation must drop WorldSFX by 46 dB. Sprint 12 uses -5.0 dB.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
var dip_db: float = buses["WorldSFX"]
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
func test_d069_confrontation_dips_ambient() -> void:
## D-069: Confrontation suppresses both Ambient and WorldSFX.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("Ambient")).is_true()
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
assert_that(spec.has("filter_hz")).is_true()
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
## Confrontation is the one that mutes world SFX.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
func test_d069_listening_focus_boosts_world_sfx() -> void:
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
assert_that(buses["WorldSFX"] > 0.0).is_true()
func test_d069_all_profiles_have_positive_ease_times() -> void:
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
for profile in AudioManager.DIP_SPECS.keys():
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
assert_that(spec.has("ease_in")).is_true()
assert_that(spec.has("ease_out")).is_true()
assert_that(spec["ease_in"] > 0.0).is_true()
assert_that(spec["ease_out"] > 0.0).is_true()
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(conf_out >= dial_out).is_true()
# ==============================================================================
# Layer 2b: D-067 Recognition Chime
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
# ==============================================================================
func test_d067_chime_recognition_constant_exists() -> void:
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
## "Neural lattice firing" feel — soft crystalline tone.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
## No further assertion needed — play() default bus IS UISounds by design.
func test_d067_chime_noop_when_asset_absent() -> void:
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
## play(CHIME_RECOGNITION) must be a no-op (no crash).
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 3: AudioManager API — Dip State Machine
# ==============================================================================
func test_audio_manager_initial_state_no_active_dip() -> void:
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_sets_active() -> void:
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_audio_manager_clear_dip_resets_active() -> void:
AudioManager.apply_dip("confrontation")
AudioManager.clear_dip()
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
## Unknown profile: push_warning + early return, active dip stays empty.
AudioManager.apply_dip("not_a_real_profile")
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
## apply_dip() must emit dip_changed(profile) synchronously.
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
## so a mutable reference type is required to observe signal argument inside the closure.
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("dialogue")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("dialogue")
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
## clear_dip() must emit dip_changed("") to signal audio restored.
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := ["sentinel"]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.clear_dip()
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("")
func test_audio_manager_apply_dip_interrupts_previous() -> void:
## Switching profiles mid-dip: active profile must update to the new one.
AudioManager.apply_dip("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
## Switching from dialogue to confrontation emits dip_changed("confrontation").
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("confrontation")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("confrontation")
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
## has_asset() must return false for a key that was never registered.
## Ensures no-op fallback path (D-038) is correctly guarded.
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
# ==============================================================================
# Layer 4: D-018 Snapshot Integration — #125 implemented
# GameState partitions sound_events by range_category into close_sound_events
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
# ==============================================================================
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
## #125: Close-range events must land in GameState.close_sound_events.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
## #126/#125: Medium-range events land in medium_sound_events, not close.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(0)
func test_snapshot_partitions_close_and_medium_events() -> void:
## D-018: Mixed batch — Close and Medium events partitioned correctly.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events.size()).is_equal(1)
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
## When snapshot has no sound_events, both close and medium arrays are cleared.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.close_sound_events.size()).is_equal(0)
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# ==============================================================================
# Layer 5: AudioManager.play_sound_event — asset registry and routing
# ==============================================================================
func test_audio_manager_sound_event_assets_registry_exists() -> void:
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
## #125 / D-038: Unknown event types must be silently skipped (no-op).
## play_sound_event should not crash and should not emit sound.
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
## #125: Empty event type string must be a no-op.
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
# ==============================================================================
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
# ==============================================================================
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
func test_d071_stationary_ticks_resets_on_movement() -> void:
## D-071: stationary_ticks must reset to 0 when the player position changes.
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
assert_that(GameState.stationary_ticks).is_equal(1)
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
assert_that(GameState.stationary_ticks).is_equal(0)
func test_d071_stationary_ticks_reaches_threshold() -> void:
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
for tick in range(31):
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
assert_that(GameState.stationary_ticks >= 30).is_true()
# ==============================================================================
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
# ==============================================================================
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_d069_listening_focus_cleared_by_dialogue() -> void:
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
AudioManager.apply_dip("listening_focus")
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_d069_clear_dip_noop_when_empty() -> void:
## D-069: clear_dip() when no dip active must be a safe no-op.
AudioManager.clear_dip() # Should not crash
assert_that(AudioManager.get_active_dip()).is_equal("")
+634
View File
@@ -0,0 +1,634 @@
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
##
## Spec refs:
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
##
## Tickets covered:
## #529 — Zone crossfade implementation (set_zone body + tween timing)
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
## #531 — Recognition chime fires at cognitive delay onset
## #533 — NPC conversation murmur wired to WorldSFX bus
##
## Test layers:
## 1. Zone crossfade spec and API (D-073 / #529)
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
## 3. Volume slider proportional dip (D-068 / D-069)
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
## 5. Recognition chime onset verification (D-067 / #531)
## 6. NPC murmur routing (D-072 / #533)
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
class_name TestAudioSprint13
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
func after_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
# ==============================================================================
# Layer 1: Zone Crossfade — D-073 / #529
# set_zone() stub exists now; full implementation lands in #529.
# ==============================================================================
func test_d073_set_zone_method_exists() -> void:
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
assert_that(AudioManager.has_method("set_zone")).is_true()
func test_d073_set_zone_hub_does_not_crash() -> void:
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
AudioManager.set_zone("hub")
func test_d073_set_zone_bar_does_not_crash() -> void:
## D-073: set_zone("bar") is a safe call.
AudioManager.set_zone("bar")
func test_d073_set_zone_corridor_does_not_crash() -> void:
## D-073: set_zone("corridor") is a safe call.
AudioManager.set_zone("corridor")
func test_d073_set_zone_empty_string_does_not_crash() -> void:
## D-073: set_zone("") edge case — no zone ID. Must not crash.
AudioManager.set_zone("")
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
AudioManager.set_zone("nonexistent_zone_xyz")
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
## Test verifies naming convention is documentable. Activates once #529 adds the map.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("hub")).is_true()
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
## D-073 / #529: bar → amb_bar_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("bar")).is_true()
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
## D-073 / #529: smuggling corridor → amb_corridor_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("corridor")).is_true()
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
## scan in main.gd per D-020 server-authoritative state.
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
],
})
assert_that(GameState.current_zone_id).is_equal("bar")
GameState.apply_snapshot({"tick": 2})
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
})
assert_that(GameState.current_zone_id).is_equal("")
GameState.apply_snapshot({"tick": 2})
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
## D-073: Crossfade tween duration must be 1.5-2.0s.
## Activates once #529 defines the duration constant.
if not "CROSSFADE_DURATION" in AudioManager:
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
return
var duration: float = AudioManager.CROSSFADE_DURATION
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
## D-073: Crossing back to the current zone should not restart a crossfade.
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
return
AudioManager.set_zone("hub")
AudioManager.set_zone("hub")
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
func test_d073_rapid_zone_crossing_interruptible() -> void:
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
## volume — new tween starts from current position. No stacked tweens, no crash.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
return
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
AudioManager.set_zone("hub")
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
## Zone tweens array should only contain active tweens from the last set_zone call.
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
AudioManager.stop_all_loops()
func test_d068_load_prefs_persistence_roundtrip() -> void:
## #528: Volume persistence — save/load roundtrip via ConfigFile.
## Sets non-default volumes, saves, reloads, and verifies values match.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
## _save_prefs() fires inside set_volume() — prefs file is written.
## Reload prefs by calling _load_prefs() directly.
AudioManager._load_prefs()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
## Reset to defaults for subsequent tests.
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
## #528: _load_prefs() with a missing/corrupted config file must not crash.
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
## This test documents the graceful fallback behavior.
AudioManager._load_prefs()
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 2: D-069 Dip Timing and dB Spec Values
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
# ==============================================================================
func test_d069_dialogue_ease_in_is_300ms() -> void:
## D-069: Dialogue dip ease-in = 300ms (0.3s).
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.3, 0.05)
func test_d069_dialogue_ease_out_is_500ms() -> void:
## D-069: Dialogue dip ease-out = 500ms (0.5s).
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(ease_out).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_in_is_500ms() -> void:
## D-069: Confrontation dip ease-in = 500ms (0.5s).
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_out_is_1000ms() -> void:
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
assert_that(ease_out).is_equal_approx(1.0, 0.05)
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
## must NOT appear in the spec — world events remain audible during conversation.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
assert_that(buses.has("PlayerActions")).is_false()
assert_that(buses.has("UISounds")).is_false()
assert_that(buses.has("Music")).is_false()
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("PlayerActions")).is_false()
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("UISounds")).is_false()
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("Ambient")).is_false()
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
func test_d069_confrontation_filter_hz_is_800hz() -> void:
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
# ==============================================================================
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
# ==============================================================================
func test_d068_set_volume_get_volume_roundtrip() -> void:
## D-068: set_volume / get_volume roundtrip preserves the slider value.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
func test_d068_set_volume_persists_across_all_buses() -> void:
## D-068: Each of the 5 buses has an independent volume setting.
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
## D-069: get_volume() always returns the player slider setting (base).
## The effective AudioServer volume during a dip = base + offset_db.
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
## Even during dip, get_volume returns the base (not base + dip offset).
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
func test_d069_set_volume_during_active_dip_updates_base() -> void:
## D-069: Changing slider mid-dip must update the base so the proportional
## calculation uses the new slider value (not the pre-dip value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
## must still reflect the player slider setting (not the dipped value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.apply_dip("dialogue")
AudioManager.clear_dip()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
# ==============================================================================
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
#
# These tests verify GameState has the snapshot fields needed for #530 wiring.
# The assertions on AudioManager dip activation are stubbed pending implementation.
# ==============================================================================
func test_d069_game_state_current_dialogue_field_exists() -> void:
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
## Field must exist so #530 wiring can check it.
assert_that("current_dialogue" in GameState).is_true()
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael", "npc_entity_id": 2,
"speech": "Haven't seen you around.", "options": [],
},
})
assert_that(GameState.current_dialogue != null).is_true()
assert_that(GameState.current_dialogue is Dictionary).is_true()
GameState.apply_snapshot({"tick": 2})
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue == null).is_true()
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
AudioManager.clear_dip()
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
## Precondition: dialogue IS active in GameState.
assert_that(GameState.current_dialogue != null).is_true()
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
GameState.apply_snapshot({"tick": 2})
AudioManager.clear_dip()
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
## TODO(#530): Uncomment the assertion once call site is wired.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2}) # dialogue ends
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("")
assert_that(GameState.current_dialogue == null).is_true()
func test_d071_listening_focus_gate_is_30_ticks() -> void:
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
## The tick gate is the CALLER's responsibility per AudioManager comment.
## This test documents the threshold so it doesn't silently drift.
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
const LISTENING_FOCUS_TICK_GATE := 30
## Stub: verify the spec value is documented.
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
## Verify AudioManager does not expose a confrontation_ui_shown signal.
var signals: Array = AudioManager.get_signal_list().map(
func(s: Dictionary) -> String: return s.name
)
assert_that(signals.has("confrontation_ui_shown")).is_false()
assert_that(signals.has("listening_focus_shown")).is_false()
# ==============================================================================
# Layer 5: Recognition Chime Onset — D-067 / #531
#
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
# pending_recognitions), NOT at completion (when it leaves).
# ==============================================================================
func test_d067_chime_recognition_constant_defined() -> void:
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
## Key must match filename stem in res://audio/.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
## Verify play() default bus is UISounds (the chime caller uses the default).
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
## Client must not crash when audio branch has not yet provided the .ogg file.
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion needed — absence of crash is the test.
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
## D-067: "Onset" of cognitive delay = first frame an entity appears in
## pending_recognitions, at remaining_ticks == total_delay_ticks.
## This is the moment the chime must fire.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
var rec: Dictionary = GameState.pending_recognitions[0]
## Onset condition: remaining == total (delay just started)
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
GameState.apply_snapshot({"tick": 2})
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
## The chime must NOT fire at this point — it fired at onset.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 1, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
## Completion: entity removed from array
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
## Chime state: AudioManager must not have a dip triggered by recognition.
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
## This test verifies FogEntities correctly identifies the onset condition.
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6}
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 3, "total_delay_ticks": 6}
## Frame 1: entity APPEARS (onset — chime should fire here)
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
## Frame 2: entity mid-progress (chime must NOT re-fire)
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
## Frame 3: entity completes (chime must NOT fire)
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
## This test documents the spec range so asset authoring can be validated.
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
## return a value in this range.
const CHIME_MIN_DURATION := 0.3
const CHIME_MAX_DURATION := 0.4
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
return
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
if stream == null:
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
return
assert_that(stream.get_length() >= CHIME_MIN_DURATION
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
# ==============================================================================
# Layer 6: NPC Murmur Routing — D-072 / #533
# Single universal asset, WorldSFX bus, no zone-specific variants.
# ==============================================================================
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
## by that bus's noise floor relative to the murmur volume.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d072_play_at_method_accepts_bus_parameter() -> void:
## D-072 / #533: play_at() must accept an optional bus string parameter.
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
assert_that(AudioManager.has_method("play_at")).is_true()
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_play_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_no_zone_specific_murmur_variants() -> void:
## D-072: SINGLE universal murmur asset — no zone-specific variants.
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
func test_d072_murmur_does_not_use_ambient_bus() -> void:
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
## Verify bus constant distinction.
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
# ==============================================================================
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
# ==============================================================================
func test_d068_registry_size_is_non_negative() -> void:
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
assert_that(AudioManager.get_registry_size() >= 0).is_true()
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
assert_that(result == null).is_true()
func test_d068_stop_loop_noop_for_unknown_key() -> void:
## D-068: stop_loop() on a key never started — no crash, no error.
AudioManager.stop_loop("nonexistent_key_xyzabc")
func test_d068_stop_all_loops_when_none_playing() -> void:
## D-068: stop_all_loops() with no active ambient players — no crash.
AudioManager.stop_all_loops()
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
func test_d068_play_noop_does_not_change_dip_state() -> void:
## D-068: play() on a missing asset must not modify the dip state machine.
## Verifies no-op fallback has zero side effects.
AudioManager.apply_dip("dialogue")
AudioManager.play("nonexistent_asset_xyzabc")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
+361
View File
@@ -0,0 +1,361 @@
## #507: WRONG button ring buffer tests.
## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd.
## Covers ring buffer capacity, circular overwrite, JSONL format compliance,
## replay compatibility, seed file, and regression against MVP behavior.
##
## MVP baseline (must still pass): snapshot.json, render.txt, description.txt.
## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt.
##
## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary)
## Sprint 11 client.md §Notes #507
class_name TestBugReportRingBuffer
extends GdUnitTestSuite
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
# Expected ring buffer capacity per spec.
const EXPECTED_CAPACITY := 60
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.current_tick = 0
GameState.room_id = null
GameState.player_stance = "Walk"
GameState.player_facing = "South"
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.rng_seed = null
# -- Helpers -------------------------------------------------------------------
func _make_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
auto_free(dialog)
add_child(dialog)
return dialog
## Create a minimal valid PlayerInput dict matching the replay.rs wire format.
## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict.
func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
return {"tick": tick, "action": action}
## Make a minimal snapshot JSON string for snapshot buffer testing.
func _make_snapshot_json(tick: int) -> String:
return JSON.stringify({
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
})
## Check a string is valid JSON array — mirrors replay.rs line expectation.
func _is_valid_json_array(line: String) -> bool:
if line.is_empty():
return false
var result = JSON.parse_string(line)
return result != null and result is Array
# -- Ring buffer capacity ------------------------------------------------------
func test_buffer_capacity_is_60() -> void:
# Spec: "Maintain a 60-entry circular buffer"
var dialog = _make_dialog()
if not dialog.has_method("_get_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_buffer_capacity()).override_failure_message(
"Ring buffer capacity must be 60 per spec"
).is_equal(EXPECTED_CAPACITY)
func test_snapshot_buffer_capacity_is_60() -> void:
# Parallel snapshot buffer must match input buffer capacity.
var dialog = _make_dialog()
if not dialog.has_method("_get_snapshot_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY)
# -- Pre-allocation -----------------------------------------------------------
func test_input_buffer_preallocated_at_ready() -> void:
# Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick."
var dialog = _make_dialog()
if not dialog.has_method("_get_input_buffer"):
push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507")
return
var buf = dialog._get_input_buffer()
assert_that(buf is Array).override_failure_message(
"Input buffer must be an Array"
).is_true()
assert_that(buf.size()).override_failure_message(
"Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY
).is_equal(EXPECTED_CAPACITY)
# -- Push and fill behavior ---------------------------------------------------
func test_push_fills_input_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs"):
push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507")
return
if not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"10 pushed ticks should produce filled count of 10"
).is_equal(10)
func test_push_fills_snapshot_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot"):
push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507")
return
if not dialog.has_method("_get_filled_snapshot_count"):
push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_snapshot(_make_snapshot_json(i))
assert_that(dialog._get_filled_snapshot_count()).is_equal(10)
# -- Circular overwrite -------------------------------------------------------
func test_circular_overwrite_evicts_oldest() -> void:
# After 61 pushes, filled count must be 60 (oldest evicted).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)"
).is_equal(EXPECTED_CAPACITY)
func test_circular_overwrite_keeps_newest_inputs() -> void:
# After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507")
return
# Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present.
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")])
var jsonl: String = dialog._format_inputs_jsonl()
# Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten.
# We check that the output has exactly 60 lines.
var lines: PackedStringArray = jsonl.split("\n", false)
# Filter out blank lines (PackedStringArray has no filter — convert to Array first)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"After 61 pushes, JSONL output must have exactly 60 non-blank lines"
).is_equal(EXPECTED_CAPACITY)
func test_exact_60_pushes_no_eviction() -> void:
# After exactly 60 pushes, all 60 are present.
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After exactly 60 pushes, all 60 entries should be present"
).is_equal(EXPECTED_CAPACITY)
# -- JSONL format compliance (replay.rs contract) ----------------------------
func test_inputs_jsonl_each_line_is_json_array() -> void:
# replay.rs: "Each line is a JSON array of PlayerInput for one tick."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_inputs(i, [_make_input(i)])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
for line in non_blank:
assert_that(_is_valid_json_array(line)).override_failure_message(
"Each JSONL line must be a valid JSON array, got: %s" % line
).is_true()
func test_inputs_jsonl_idle_tick_is_empty_array() -> void:
# replay.rs: "Empty array = idle tick (no input sent)."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, []) # Idle tick: no inputs
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"One idle tick should produce exactly 1 JSONL line"
).is_equal(1)
assert_that(non_blank[0].strip_edges()).override_failure_message(
"Idle tick line must be an empty JSON array '[]'"
).is_equal("[]")
func test_inputs_jsonl_has_tick_field() -> void:
# replay.rs PlayerInput expects {"tick": N, "action": "..."}
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")])
var jsonl: String = dialog._format_inputs_jsonl()
# Parse the first line and verify structure
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_greater(0)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size() > 0).is_true()
var first_input: Dictionary = arr[0]
assert_that(first_input.has("tick")).override_failure_message(
"Each PlayerInput in JSONL must have 'tick' field"
).is_true()
assert_that(first_input.has("action")).override_failure_message(
"Each PlayerInput in JSONL must have 'action' field"
).is_true()
func test_inputs_jsonl_multiple_actions_per_tick() -> void:
# replay.rs supports multiple PlayerInput per tick (one array entry per action).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, [
_make_input(0, "MoveNorth"),
_make_input(0, "Pause"),
])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(1)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size()).override_failure_message(
"Tick with 2 actions must have 2 entries in the JSONL array"
).is_equal(2)
func test_snapshots_jsonl_each_line_is_valid_json() -> void:
# Each snapshot line should be valid JSON (parsed snapshot).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"):
push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_snapshot(_make_snapshot_json(i))
var jsonl: String = dialog._format_snapshots_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(3)
for line in non_blank:
var parsed = JSON.parse_string(line)
assert_that(parsed != null).override_failure_message(
"Each snapshot line must be valid JSON, got: %s" % line
).is_true()
assert_that(parsed is Dictionary).override_failure_message(
"Each snapshot line must parse to a Dictionary"
).is_true()
# -- Seed file ----------------------------------------------------------------
func test_seed_written_on_capture() -> void:
# Spec: "Include the server's current RNG seed... Output as seed.txt"
# We can't easily verify file output in headless mode, but we verify
# the dialog has the method to produce the seed value.
var dialog = _make_dialog()
if not dialog.has_method("_get_current_seed"):
push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507")
return
# Should return some Variant (int or String) without crashing
var seed_val = dialog._get_current_seed()
assert_that(seed_val != null).override_failure_message(
"_get_current_seed() must not return null (use 0 or 'unavailable' if seed unavailable)"
).is_true()
# -- Room metadata regression -------------------------------------------------
func test_description_txt_still_contains_room_id() -> void:
# Regression: MVP description.txt must still include room metadata.
GameState.current_tick = 5
GameState.room_id = "warehouse_01"
GameState.player_stance = "Walk"
GameState.player_facing = "North"
GameState.player_position = Vector2(10.0, 10.0)
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
# Call the render text method to verify room shows up in render output
# (description.txt itself writes via FileAccess, difficult in headless — test the render path)
if dialog.has_method("_render_snapshot_text"):
# The text render is for render.txt, but description.txt room comes from GameState.room_id
# We indirectly verify by checking that the game state has the right fields.
assert_that(GameState.room_id).is_equal("warehouse_01")
GameState.room_id = null
# -- MVP regression -----------------------------------------------------------
func test_render_snapshot_text_still_works() -> void:
# Regression: MVP _render_snapshot_text() must still work after upgrade.
GameState.current_tick = 10
GameState.player_position = Vector2(5.0, 5.0)
GameState.player_facing = "East"
GameState.player_stance = "Careful"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
if not dialog.has_method("_render_snapshot_text"):
push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk")
return
var text: String = dialog._render_snapshot_text()
assert_that(text.length() > 0).is_true()
assert_that(text.contains("t10")).is_true()
func test_dialog_is_active_api_unchanged() -> void:
# Regression: public API from MVP must be unchanged.
var dialog = _make_dialog()
assert_that(dialog.has_method("start_capture")).override_failure_message(
"start_capture() must still be present (MVP regression)"
).is_true()
assert_that(dialog.has_method("is_active")).override_failure_message(
"is_active() must still be present (MVP regression)"
).is_true()
+40
View File
@@ -305,6 +305,46 @@ func test_cursor_changes_require_los() -> void:
cursor.queue_free()
# -- OQ-07: Insert-off behavior (D-056 amendment, #522) ----------------------
# Option (a): cursor shape still changes, verb labels suppressed.
func test_insert_off_cursor_still_changes_shape() -> void:
# OQ-07 option (a): cursor state machine still fires when insert is off.
# The character's body orients toward targets even without insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("set_hover_target"):
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_insert_off_suppresses_interactions() -> void:
# OQ-07 option (a): with insert off, should_show_interactions() returns false.
# Verb labels (z-layer 6) are suppressed — no actionable insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_insert_on_restores_interaction_display() -> void:
# Re-enabling insert allows interactions to show again.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
+2 -2
View File
@@ -129,13 +129,13 @@ func test_gauntlet_mode_transitions_off() -> void:
func test_teleport_hub_wire_name() -> void:
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name).is_equal("TeleportToHub")
func test_teleport_hub_wire_not_empty() -> void:
# Wire name must not be empty (empty = client-only, not sent to server)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
+400
View File
@@ -0,0 +1,400 @@
## #522: OQ-07 resolution — no-insert interaction behavior.
## Tests that when insert_active == false, interaction labels disappear
## (diegetic test from D-056 and D-057), and that cursor and interaction
## list agree on insert state.
##
## D-056: "Interaction labels render on z-layer 6 (insert overlay).
## If the insert is off, labels disappear."
## D-057: "Labels render on z-layer 6. If insert is off, labels disappear."
##
## OQ-07 resolution (expected option a): cursor shape still changes
## (character physically orients), but verb labels are suppressed.
##
## Spec refs: D-056, D-057, D-048, D-049
## Ticket #522
class_name TestInsertOffBehavior
extends GdUnitTestSuite
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
GameState.insert_active = true
# -- Helpers -------------------------------------------------------------------
func _make_cursor() -> Node:
for path in ["res://ui/cursor_state_machine.tscn", "res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
return null
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
push_warning("TestInsertOffBehavior: cursor scene not found — test skipped (awaiting #522)")
return cursor
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
add_child(node)
return node
return null
func _make_list_or_skip() -> Node:
var list = _make_interaction_list()
if list == null:
push_warning("TestInsertOffBehavior: interaction list scene not found — test skipped (awaiting #522)")
return list
func _set_test_interactions() -> void:
GameState.nearby_interactions = [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
func _clear_test_interactions() -> void:
GameState.nearby_interactions = []
# -- Interaction list: diegetic test (D-056, D-057) ---------------------------
func test_insert_off_hides_interaction_list() -> void:
# D-056/D-057 diegetic test: "If the insert is off, labels disappear."
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"D-056/D-057 diegetic test: interaction list must be hidden when insert is off"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).override_failure_message(
"Interaction list must show 0 verbs when insert is off"
).is_equal(0)
list.queue_free()
_clear_test_interactions()
func test_insert_on_shows_interaction_list() -> void:
# When insert is on (normal state), verbs should be visible.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Interaction list must be visible when insert is on and verbs present"
).is_true()
list.queue_free()
_clear_test_interactions()
func test_insert_reenable_restores_list() -> void:
# Toggling insert off then on restores verb list visibility.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if not list.has_method("set_insert_active"):
list.queue_free()
_clear_test_interactions()
return
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Re-enabling insert must restore list visibility"
).is_true()
list.queue_free()
_clear_test_interactions()
# -- Cursor: insert-off behavior (OQ-07 resolution option a) ------------------
func test_insert_off_suppresses_should_show_interactions() -> void:
# OQ-07 option (a): labels are suppressed when insert is off.
# Regardless of cursor visual state, should_show_interactions() must return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return false when insert is off"
).is_false()
cursor.queue_free()
func test_insert_on_restores_should_show_interactions() -> void:
# After re-enabling insert, interactions should show again (not in weapon mode).
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_insert_active(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return true after re-enabling insert"
).is_true()
cursor.queue_free()
func test_insert_off_at_startup_no_corruption() -> void:
# Insert can be off from the start — no state machine corruption.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("get_state"):
cursor.queue_free()
return
cursor.set_insert_active(false)
# Cursor must be in a valid state (not null/error)
var state_str := str(cursor.get_state())
assert_that(["Default", "EntityHover", "ObjectHover", "WeaponAim"].has(state_str)).override_failure_message(
"Cursor must be in a valid state after insert-off at startup, got: %s" % state_str
).is_true()
cursor.queue_free()
# -- OQ-07 option (a): cursor shape still changes, labels suppressed ----------
func test_insert_off_option_a_cursor_still_transitions() -> void:
# Option (a): cursor shape changes even when insert off
# (character physically orients to target, just no labels).
# If implementation chose option (b) instead, this test would fail — that's informative.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_hover_target"):
push_warning("TestInsertOffBehavior: set_insert_active or set_hover_target not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": true})
# Option (a): cursor SHOULD be in EntityHover despite insert being off
# Option (b): cursor would stay in Default
# Test documents expected behavior — fail message explains which option is active
if cursor.has_method("get_state"):
var state := str(cursor.get_state())
# If this assertion fails, option (b) was implemented (cursor full-suppressed).
# Neither (a) nor (b) is wrong — this test documents which was chosen.
# Consult decisions/perception.md for the OQ-07 amendment.
assert_that(state).override_failure_message(
"OQ-07 option (a): cursor should still transition to EntityHover when insert is off. "
+ "If this fails, option (b) was implemented (full suppression) — update this test to assert 'Default' instead."
).is_equal("EntityHover")
cursor.queue_free()
# -- Edge cases ---------------------------------------------------------------
func test_insert_off_plus_weapon_mode_stays_suppressed() -> void:
# Insert-off AND weapon mode: should_show_interactions() must still return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_weapon_mode"):
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off + weapon mode must keep should_show_interactions() false"
).is_false()
cursor.queue_free()
func test_insert_off_shift_override_still_suppressed() -> void:
# OQ-07: Insert-off trumps Shift override.
# Shift restores interactions in weapon mode, but insert-off is a harder constraint.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_shift_held"):
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off must suppress interactions even with Shift held (insert-off > shift override)"
).is_false()
cursor.queue_free()
func test_insert_off_rapid_toggle_no_corruption() -> void:
# Rapid on/off toggle must not leave either system in inconsistent state.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
cursor.queue_free()
return
for _i in range(10):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
# After 10 toggles, insert should be on
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"After rapid toggle ending on 'on', interactions must be visible"
).is_true()
cursor.queue_free()
# -- Cross-system consistency (cursor + list agree) ---------------------------
func test_cursor_and_list_agree_on_insert_off() -> void:
# Both cursor and interaction list must be suppressed when insert is off.
# This tests the integration contract — both must read from the same source of truth.
var cursor = _make_cursor_or_skip()
var list = _make_list_or_skip()
if cursor == null or list == null:
if cursor != null: cursor.queue_free()
if list != null: list.queue_free()
return
_set_test_interactions()
if cursor.has_method("set_insert_active") and list.has_method("set_insert_active"):
cursor.set_insert_active(false)
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
var cursor_suppressed := true
if cursor.has_method("should_show_interactions"):
cursor_suppressed = not cursor.should_show_interactions()
var list_suppressed := true
if list.has_method("is_showing"):
list_suppressed = not list.is_showing()
elif list.has_method("get_visible_verb_count"):
list_suppressed = list.get_visible_verb_count() == 0
assert_that(cursor_suppressed).override_failure_message(
"Cursor: should_show_interactions() must return false when insert is off"
).is_true()
assert_that(list_suppressed).override_failure_message(
"Interaction list: must be hidden when insert is off"
).is_true()
cursor.queue_free()
list.queue_free()
_clear_test_interactions()
func test_game_state_insert_active_field_exists() -> void:
# GameState must have an insert_active field after #522 implementation.
assert_that(GameState.get("insert_active") != null or "insert_active" in GameState).override_failure_message(
"GameState must have insert_active field after #522 — awaiting implementation"
).is_true()
func test_game_state_insert_active_default_true() -> void:
# Default state: insert is on (player starts with a functioning neural insert).
if not ("insert_active" in GameState):
push_warning("TestInsertOffBehavior: GameState.insert_active not found — awaiting #522")
return
assert_that(GameState.insert_active).override_failure_message(
"GameState.insert_active should default to true (insert is normally on)"
).is_true()
# -- D-049: z-layer verification (labels on layer 6) -------------------------
func test_interaction_list_on_insert_layer() -> void:
# D-056/D-057: "Labels render on z-layer 6 (insert overlay)"
# This is already tested in test_interaction_list.gd but we verify here
# that the z-layer is CANVAS_INSERT (the insert overlay layer).
var list = _make_list_or_skip()
if list == null:
return
if list.has_method("get_z_layer"):
assert_that(list.get_z_layer()).override_failure_message(
"Interaction list labels must render on CANVAS_INSERT (z-layer 6 per D-049)"
).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -- Regression: existing behavior unchanged ----------------------------------
func test_sprint_suppression_still_works_with_insert_on() -> void:
# D-055 regression: Sprint suppresses interaction list regardless of insert state.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
# Ensure insert is on (should not affect sprint suppression)
if list.has_method("set_insert_active"):
list.set_insert_active(true)
GameState.player_stance = "Sprint"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Sprint must suppress interaction list even when insert is on (D-055 regression)"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
_clear_test_interactions()
GameState.player_stance = "Walk"
func test_weapon_mode_suppression_still_works_with_insert_on() -> void:
# D-056 regression: weapon mode suppresses interactions when insert is on.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active"):
cursor.set_insert_active(true) # Insert is on
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Weapon mode must suppress interactions when insert is on (D-056 regression)"
).is_false()
cursor.queue_free()
+3 -3
View File
@@ -268,12 +268,12 @@ func test_insert_off_hides_interaction_list() -> void:
func test_stance_up_wire_mapping() -> void:
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
assert_that(wire).is_equal("ToggleStanceUp")
func test_stance_down_wire_mapping() -> void:
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
assert_that(wire).is_equal("ToggleStanceDown")
@@ -295,7 +295,7 @@ func test_all_movement_actions_have_wire_mapping() -> void:
InputMapper.Action.TOGGLE_STANCE_DOWN,
]
for action in actions_with_mapping:
var wire = SimBridge._action_enum_to_wire(action)
var wire = SimBridge.action_enum_to_wire(action)
assert_that(wire.length()).is_greater(0)
+6 -6
View File
@@ -163,23 +163,23 @@ func test_action_enum_to_wire_all_directions_clockwise() -> void:
]
for pair in expected:
var wire_name := SimBridge._action_enum_to_wire(pair[0])
var wire_name := SimBridge.action_enum_to_wire(pair[0])
assert_that(wire_name).is_equal(pair[1])
func test_action_enum_to_wire_non_movement() -> void:
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
func test_action_enum_to_wire_open_menu_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.OPEN_MENU)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.OPEN_MENU)
assert_that(wire_name).is_equal("")
func test_action_enum_to_wire_unknown_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(9999)
var wire_name := SimBridge.action_enum_to_wire(9999)
assert_that(wire_name).is_equal("")
+199
View File
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
# Regression test for #345: protocol uses entity_id (not id).
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
func test_entity_renderer_rejects_old_id_field_format() -> void:
var renderer := _make_entity_renderer()
# Old (wrong) format: "id" instead of "entity_id"
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(0)
# Correct format: "entity_id" — should be accepted
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_entity_renderer_player_color_differs_from_npc() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities)
@@ -334,6 +348,191 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- EntityRenderer: regression #345 (entity_id field name bug) --
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
# entity_data.has("entity_id"), causing all entities to be silently dropped.
# Fix: use entity_id (Protocol v2 canonical field name).
func test_regression_345_entity_id_field_renders() -> void:
# Regression: entity with correct "entity_id" field MUST be rendered.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_regression_345_old_id_field_not_rendered() -> void:
# Negative regression: entity using OLD field name "id" (not "entity_id")
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
# Only the entity_id entity should appear — no cross-contamination.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(1)).is_true()
assert_that(renderer.entity_nodes.has(2)).is_false()
renderer.queue_free()
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
# not by a string "entity_id" or by the old "id" value.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.has(42)).is_true()
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
assert_that(renderer.entity_nodes.has(0)).is_false()
renderer.queue_free()
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
# Regression: entity rendered via entity_id must have correct pixel position.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
var node = renderer.entity_nodes[5]
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
renderer.queue_free()
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
func _make_sound_indicator_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(SoundIndicatorScript)
add_child(renderer)
return renderer
func test_sound_indicator_accepts_medium_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(renderer._indicators.size()).is_equal(2)
renderer.queue_free()
func test_sound_indicator_drops_events_without_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"event_type": "Footstep"}, # missing x, y
{"x": 5.0, "event_type": "Voice"}, # missing y
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_appends_new_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
])
assert_that(renderer._indicators.size()).is_equal(2)
# New update appends — existing indicators persist until they expire
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
assert_that(renderer._indicators.size()).is_equal(3)
renderer.queue_free()
func test_sound_indicator_empty_update_preserves_existing() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Empty update does not clear existing indicators — they expire via _process
renderer.update_sound_events([])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_color_voice() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
renderer.queue_free()
func test_sound_indicator_color_danger() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
renderer.queue_free()
func test_sound_indicator_color_neutral_for_unknown() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
renderer.queue_free()
func test_sound_indicator_deduplicates_same_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Same position again — should reset timer, not add a duplicate
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_elapsed_starts_at_zero() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
renderer.queue_free()
func test_sound_indicator_events_expire_after_lifetime() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
# Manually age the indicator past its lifetime
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
renderer._process(0.0) # zero delta so no additional aging
assert_that(renderer._indicators.size()).is_equal(0)
renderer.queue_free()
# -- GameState: medium_sound_events from snapshot --
func test_game_state_filters_medium_sound_events() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
GameState.apply_snapshot({"tick": 1})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
# Next tick without sound_events clears them
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+236
View File
@@ -0,0 +1,236 @@
## Test suite for #126: Medium-range visual indicators (D-018)
## Spec refs:
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
## D-049: Z-stack — indicators must render above fog layer (z:900)
## D-069: Color palette for indicators (neutral/voice/danger)
##
## Color spec (sprint brief + D-018/D-069):
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
##
## Test layers:
## 1. D-018 Indicator color spec — verifies Constants match the spec
## 2. D-018 Range routing — Medium vs Close events belong to different systems
## 3. Direction geometry — angle from player position to sound source
## 4. Snapshot integration stubs — graceful skip until #126 lands
## 5. Indicator script API — graceful skip until Stig creates the script
class_name TestSoundIndicators
extends GdUnitTestSuite
# ==============================================================================
# Layer 1: D-018/D-069 Indicator Color Spec
# Sound indicators reuse existing Constants to avoid palette drift.
# ==============================================================================
func test_d018_indicator_color_neutral_is_insert_color() -> void:
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
## Indicators use the insert visual language (diegetic, not world-layer UI).
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
func test_d018_indicator_color_voices_is_poi_amber() -> void:
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d018_indicator_color_danger_is_hostile_red() -> void:
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d018_three_indicator_colors_are_distinct() -> void:
## D-018: All three indicator states must be visually distinct.
var neutral: Color = Constants.INSERT_COLOR_TEXT
var voice: Color = Constants.ENTITY_COLOR_POI
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
assert_that(neutral != voice).is_true()
assert_that(neutral != danger).is_true()
assert_that(voice != danger).is_true()
func test_d049_fog_layer_z_index() -> void:
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
## This verifies the z constant — indicator placement depends on it.
assert_that(Constants.Z_FOG).is_equal(900)
# ==============================================================================
# Layer 2: D-018 Range Routing — Medium vs Close
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
# Medium-range events → fog-edge indicators (no direct audio)
# ==============================================================================
func test_d018_medium_range_events_stored_in_snapshot() -> void:
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
## D-018: Close events must NOT appear in medium_sound_events.
## They go to close_sound_events for 2D positional audio.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(1)
func test_d018_medium_range_event_has_position_fields() -> void:
## Medium-range events must include source position for direction calculation.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
var ev: Dictionary = GameState.medium_sound_events[0]
assert_that(ev.has("x")).is_true()
assert_that(ev.has("y")).is_true()
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
# ==============================================================================
# Layer 3: Direction Geometry
# The fog-edge indicator must point toward the sound source from the player.
# These tests validate the math used by the indicator rendering — if the
# indicator uses Vector2.angle() or atan2, these confirm expected output.
# ==============================================================================
## Compute directional angle (radians) from player tile pos to source tile pos.
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
func _direction_angle(player: Vector2, source: Vector2) -> float:
return player.direction_to(source).angle()
func test_indicator_direction_north() -> void:
## Source directly north of player (lower y in Godot) → angle -PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
func test_indicator_direction_east() -> void:
## Source directly east → angle 0.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
assert_that(angle).is_equal_approx(0.0, 0.01)
func test_indicator_direction_south() -> void:
## Source directly south (higher y) → angle PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
func test_indicator_direction_west() -> void:
## Source directly west → angle ≈ ±PI.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
func test_indicator_direction_northeast() -> void:
## Source equal offset northeast → angle -PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
func test_indicator_direction_southeast() -> void:
## Source equal offset southeast → angle PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
func test_indicator_direction_southwest() -> void:
## Source equal offset southwest → angle 3PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
func test_indicator_direction_changes_with_source_position() -> void:
## Sanity: direction is not degenerate — different source positions give different angles.
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(north != east).is_true()
assert_that(east != south).is_true()
assert_that(south != north).is_true()
func test_indicator_direction_magnitude_independent_of_distance() -> void:
## Direction angle must not vary with distance — only with angle from player.
## Source 5 tiles north vs 20 tiles north: same angle.
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
assert_that(near).is_equal_approx(far, 0.01)
# ==============================================================================
# Layer 4: Indicator Script API (graceful stub)
# Tests activate when Stig creates the indicator rendering script.
# Expected paths checked in order — first match wins.
# ==============================================================================
func _load_indicator_script() -> GDScript:
## Load the sound indicator renderer script (#126 implemented).
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
if ResourceLoader.exists(path):
return load(path) as GDScript
return null
func test_indicator_script_exists() -> void:
## #126: sound_indicator_renderer.gd must exist at the canonical path.
var script: GDScript = _load_indicator_script()
assert_that(script != null).is_true()
func test_indicator_has_update_sound_events_method() -> void:
## #126: Indicator must expose update_sound_events(events: Array).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var method_names: Array = script.get_script_method_list().map(
func(m: Dictionary) -> String: return m.name
)
assert_that(method_names.has("update_sound_events")).is_true()
func test_indicator_does_not_crash_for_empty_events() -> void:
## Edge case: empty sound_events list must not crash update_sound_events.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([])
# Empty update on fresh renderer — no indicators exist, no crash
assert_that(indicator._indicators.size()).is_equal(0)
indicator.queue_free()
func test_indicator_existing_survive_empty_update() -> void:
## Existing indicators persist through an empty update (expire via _process only).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.update_sound_events([])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.queue_free()
func test_indicator_stores_valid_medium_events() -> void:
## #126: update_sound_events must store events with x and y fields.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(indicator._indicators.size()).is_equal(2)
indicator.queue_free()
+222 -5
View File
@@ -1,8 +1,23 @@
extends Control
## #495: WRONG button (F12) MVP — bug report capture dialog.
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
## Upgrade of the Sprint 9 MVP (#495).
##
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
## - snapshot.json — single-tick point-in-time (MVP compat)
## - render.txt — client-side text render
## - description.txt — tester notes + room/tick/seed metadata
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
##
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
signal capture_completed
signal capture_cancelled
@@ -16,14 +31,172 @@ const BOX_WIDTH := 500
const BOX_HEIGHT := 120
const PADDING := 16
# #507: Ring buffer capacity — 60 ticks of history
const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
# Separate heads and counts so each buffer can be tested independently.
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
# debug tool that is always active during Gauntlet play.
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
var _snapshot_head: int = 0
var _snapshot_count: int = 0
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
# Pre-allocate ring buffers — resize then fill sentinels.
# The ring array itself never grows after _ready. Each write replaces the GDScript
# reference in an existing slot (not a new allocation of the ring), though the input
# Array stored per slot is a fresh ref each tick.
_input_ring.resize(RING_SIZE)
_snapshot_ring.resize(RING_SIZE)
for i in range(RING_SIZE):
_input_ring[i] = []
_snapshot_ring[i] = ""
# ---------------------------------------------------------------------------
# Public API for main.gd: record one tick's data
# ---------------------------------------------------------------------------
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
## - tick: current server tick number
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
## These are in raw InputMapper format and will be converted to replay format.
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
# Convert mapper inputs to replay-compatible format, then push both buffers.
var replay_inputs: Array = []
for inp in mapper_inputs:
var ri := _to_replay_format(inp, tick)
if not ri.is_empty():
replay_inputs.append(ri)
_push_tick_inputs(tick, replay_inputs)
_push_tick_snapshot(snapshot_json)
# ---------------------------------------------------------------------------
# Internal ring buffer operations (also exposed for tests)
# ---------------------------------------------------------------------------
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
## Overwrites oldest entry when buffer is full (circular eviction).
@warning_ignore("unused_parameter")
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
_input_ring[_input_head] = inputs
_input_head = (_input_head + 1) % RING_SIZE
if _input_count < RING_SIZE:
_input_count += 1
## Push a JSON-serialized ObserverSnapshot string for one tick.
func _push_tick_snapshot(snapshot_json: String) -> void:
_snapshot_ring[_snapshot_head] = snapshot_json
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
if _snapshot_count < RING_SIZE:
_snapshot_count += 1
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
## Returns a String with one JSON array per line, oldest to newest.
## Each line: Array of {tick, action} replay-format PlayerInput objects.
func _format_inputs_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
for i in range(_input_count):
var idx := (start + i) % RING_SIZE
lines.append(JSON.stringify(_input_ring[idx]))
return "\n".join(lines)
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
## Returns a String with one JSON string per line, oldest to newest.
func _format_snapshots_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
for i in range(_snapshot_count):
var idx := (start + i) % RING_SIZE
lines.append(_snapshot_ring[idx])
return "\n".join(lines)
## Return the RNG seed for seed.txt. Never returns null.
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
## loses precision above 2^53 via float intermediary. When the server field
## lands, consider string-encoding the seed to preserve all 64 bits.
func _get_current_seed() -> Variant:
if GameState.rng_seed != null:
return GameState.rng_seed
return "unavailable"
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
## Replay format: {"tick": N, "action": "MoveNorth"} or
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
var action_enum: int = input.get("action", -1)
var wire: String = SimBridge.action_enum_to_wire(action_enum)
if wire.is_empty():
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
var result := {"tick": tick}
var action_data: Variant = input.get("action_data")
match wire:
"Interact":
# Rust PlayerAction::Interact { target_entity_id, verb }
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
"SetFacing":
# Rust PlayerAction::SetFacing(String) — wrap direction string
var facing := ""
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
result["action"] = {"SetFacing": facing}
_:
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
result["action"] = wire
return result
# ---------------------------------------------------------------------------
# Test-accessible accessors (ring buffer introspection)
# ---------------------------------------------------------------------------
func _get_buffer_capacity() -> int:
return RING_SIZE
func _get_snapshot_buffer_capacity() -> int:
return RING_SIZE
func _get_input_buffer() -> Array:
return _input_ring
func _get_filled_input_count() -> int:
return _input_count
func _get_filled_snapshot_count() -> int:
return _snapshot_count
# ---------------------------------------------------------------------------
# UI / capture flow
# ---------------------------------------------------------------------------
func start_capture() -> void:
if _active:
@@ -94,7 +267,7 @@ func _save_report(description: String) -> void:
var files_saved := 0
# 1. snapshot.json — full current snapshot as JSON
# 1. snapshot.json — single-tick point-in-time (MVP compat, #495)
var snapshot_path := base_path + "/snapshot.json"
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
if snapshot_file:
@@ -115,6 +288,7 @@ func _save_report(description: String) -> void:
push_error("BugReport: failed to write %s" % render_path)
# 3. description.txt — tester description + metadata
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
var desc_path := base_path + "/description.txt"
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
if desc_file:
@@ -125,12 +299,53 @@ func _save_report(description: String) -> void:
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
desc_file.store_string("RingBufferTicks: %d\n" % _input_count)
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
# One JSON array per line. Empty array = idle tick.
# Compatible with tooling/test-client --replay (replay.rs).
var inputs_path := base_path + "/inputs.jsonl"
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
if inputs_file:
inputs_file.store_string(_format_inputs_jsonl())
inputs_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % inputs_path)
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
var snaps_path := base_path + "/snapshots.jsonl"
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
if snaps_file:
snaps_file.store_string(_format_snapshots_jsonl())
snaps_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snaps_path)
# 6. seed.txt — RNG seed for deterministic replay.
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
# If absent: includes a note on the required protocol change.
var seed_path := base_path + "/seed.txt"
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
if seed_file:
var seed_val: Variant = _get_current_seed()
seed_file.store_string(str(seed_val) + "\n")
if seed_val == "unavailable":
seed_file.store_string(
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
)
seed_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % seed_path)
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
## Simplified client-side text render of the current snapshot.
@@ -200,7 +415,9 @@ func _draw() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
# -- Public API ---------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func is_active() -> bool:
return _active
+11 -1
View File
@@ -28,7 +28,7 @@ const FADE_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280)
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
@@ -139,6 +139,12 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
_is_showing = true
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
# D-069: Dialogue dip — reduce ambient noise to foreground conversation.
# Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation")
# in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base
# volumes regardless of which profile was last active — intentional replacement semantics.
AudioManager.apply_dip("dialogue")
# D-061: auto-pause in single-player when dialogue opens
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
@@ -156,6 +162,10 @@ func hide_dialogue() -> void:
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
# D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings.
# Safe to call even if confrontation beat already cleared the dip (no-op when empty).
AudioManager.clear_dip()
# D-061: unpause when dialogue closes
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
+2 -2
View File
@@ -20,9 +20,9 @@ anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -416.0
offset_left = -320.0
offset_top = -200.0
offset_right = 416.0
offset_right = 320.0
grow_horizontal = 2
grow_vertical = 0
+15
View File
@@ -13,6 +13,8 @@ extends PanelContainer
var _is_showing: bool = false
var _active_tween: Tween = null
var _current_target_id: int = -1
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
var _insert_active: bool = true
const FADE_IN: float = 0.15
const FADE_OUT: float = 0.15
@@ -23,6 +25,11 @@ func _ready() -> void:
_is_showing = false
func _process(_delta: float) -> void:
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
if not _insert_active:
if _is_showing:
_hide_prompt()
return
var interactions: Array = GameState.nearby_interactions
if interactions.size() > 0:
_show_prompt(interactions[0])
@@ -69,6 +76,14 @@ func _hide_prompt() -> void:
func get_interaction_target() -> int:
return _current_target_id
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
## Cursor shape changes still fire on cursor_renderer.gd.
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _is_showing:
_hide_prompt()
## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection).
func get_selected_verb() -> String:
var interactions: Array = GameState.nearby_interactions
+159
View File
@@ -0,0 +1,159 @@
extends Control
## #528: Audio settings dialog — 5-bus volume sliders.
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
## Volumes persist via AudioManager._save_prefs() on each slider change.
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
const TITLE_COLOR := Color("#4a9ebb")
const FONT_SIZE := 14
const BOX_WIDTH := 460
const BOX_HEIGHT := 340
const PADDING := 20
const ROW_HEIGHT := 36
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
const BUS_ROWS: Array = [
["Music", "Music"],
["Ambient", "Ambient"],
["World SFX", "WorldSFX"],
["Player Actions", "PlayerActions"],
["UI Sounds", "UISounds"],
]
var _active: bool = false
var _container: VBoxContainer = null
signal closed
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func open() -> void:
if _active:
return
_active = true
visible = true
_build_ui()
queue_redraw()
func close() -> void:
if not _active:
return
_active = false
visible = false
_destroy_ui()
queue_redraw()
closed.emit()
func is_open() -> bool:
return _active
func _build_ui() -> void:
var vp_size := get_viewport_rect().size
var box_pos := Vector2(
(vp_size.x - BOX_WIDTH) / 2.0,
(vp_size.y - BOX_HEIGHT) / 2.0
)
_container = VBoxContainer.new()
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
_container.add_theme_constant_override("separation", 4)
add_child(_container)
for row_data in BUS_ROWS:
var label_text: String = row_data[0]
var bus_name: String = row_data[1]
var hbox := HBoxContainer.new()
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(hbox)
var label := Label.new()
label.text = label_text
label.custom_minimum_size = Vector2(150, 0)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(label)
var slider := HSlider.new()
slider.min_value = -40.0
slider.max_value = 0.0
slider.step = 0.5
slider.value = AudioManager.get_volume(bus_name)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(slider)
var db_label := Label.new()
db_label.text = _format_db(slider.value)
db_label.custom_minimum_size = Vector2(70, 0)
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
db_label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(db_label)
slider.value_changed.connect(func(value: float) -> void:
AudioManager.set_volume(bus_name, value)
db_label.text = _format_db(value)
)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 8)
_container.add_child(spacer)
# Close button
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
close_btn.pressed.connect(close)
_container.add_child(close_btn)
func _destroy_ui() -> void:
if _container:
_container.queue_free()
_container = null
func _draw() -> void:
if not _active:
return
var viewport_size := get_viewport_rect().size
# Dim overlay
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Dialog box
var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0,
(viewport_size.y - BOX_HEIGHT) / 2.0
)
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
# Title
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, PADDING + 18),
"AUDIO SETTINGS",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
return "%d dB" % int(db)
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
[node name="SettingsDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_settings")
@@ -1,2 +1,379 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: ring-operative at Maintenance Corridors
# NPC: Generic ring operative (Tier 3 template — members without individual profiles)
# Voice: two registers. Surface: maintenance worker, brief, task-focused. Real: coded, precise, never explicit.
# The crucial thing: both registers sound identical to a casual listener.
# Ring-aware: full ring membership. Knows the route, the window, the players.
# Ring code vocabulary (from Sova texture appendix):
# "running maintenance" = cover for ring operation
# "the usual pickup" = ring cargo handoff
# "cans are ready" = containers cleared for movement
# "manifest is clean" = paperwork cover confirmed
# "Drin was asking" = warning, inspection proximity
# "dead air" = Meridian surveillance gaps
# "the gap" = maintenance corridors, the route itself
# Ticket: #192 | Sprint: 12
location: maintenance-corridors
role: ring-operative
lines:
# ==========================================
# SURFACE COVER — sounds like maintenance work
# ==========================================
- id: maintenance-corridors_d_100
text: "Running maintenance on the B-7 conduit. Could be a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, b7]
dual_lens:
smuggler: "Cover phrase. He's not doing maintenance."
detective: "Maintenance worker. Conduit repair. Routine."
notes: "'Running maintenance' is the canonical ring cover phrase."
- id: maintenance-corridors_d_101
text: "Bay four access from here is faster than going around. If you're in a hurry."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, bay-four, spatial]
dual_lens:
smuggler: "He's telling me the route is faster. This is spatial information, not just courtesy."
detective: "Ring operative casually establishes corridor-to-bay-four spatial relationship. Note it."
- id: maintenance-corridors_d_102
text: "Quiet in here tonight. Good working conditions."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, ambient]
dual_lens:
smuggler: "Dead air confirmed. Meridian's not picking this up."
detective: "Routine corridor comment."
- id: maintenance-corridors_d_103
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [ring-operative, surface-cover, pael, corridor-traffic]
dual_lens:
smuggler: "Pael was through. Legitimate. Corridor's been active — that means traffic covers us."
detective: "References Pael's B-7 presence without suspicion. Maintenance workers know each other's routes."
- id: maintenance-corridors_d_104
text: "C-4 access is at the far end. About three minutes from here."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, c4, spatial]
dual_lens:
smuggler: "He's giving me the timing. Three minutes to C-4 from this point."
detective: "Operative establishes C-4 proximity to maintenance corridor. That's the link."
knowledge_grant:
fact_id: location.smuggling_route
confidence: suspects
# ==========================================
# OPERATIONAL COORDINATION — insider/real only
# ==========================================
- id: maintenance-corridors_d_105
text: "Cans are ready. Bay four, per the schedule."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, cans-ready]
dual_lens:
smuggler: "Containers cleared. Bay four. We're on."
detective: "Never hears this line."
- id: maintenance-corridors_d_106
text: "Manifest is clean. Window opens in eighteen minutes."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, manifest-clean, window]
dual_lens:
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
detective: "Never hears this line."
- id: maintenance-corridors_d_107
text: "Usual pickup. Route's the same."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition, routine]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, usual-pickup]
dual_lens:
smuggler: "Same route, same handoff. No variations."
detective: "Never hears this line."
- id: maintenance-corridors_d_108
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, scan-gap, surveillance]
dual_lens:
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_109
text: "Drin was asking about B-7. Twice today."
role: ring-operative
access: [insider]
trust: real
situation: [routine, confrontation]
topic: [danger, colleague]
mood: [concerned]
tags: [ring-operative, ring-ops, coded, drin-asking, warning]
dual_lens:
smuggler: "Drin's circling B-7. That's a warning. Push the pickup or hold it."
detective: "Never hears this line."
knowledge_grant:
fact_id: investigation.drin_inspection_pattern
confidence: suspects
- id: maintenance-corridors_d_110
text: "Dead air through here to the C-4 junction. No coverage."
role: ring-operative
access: [insider]
trust: real
situation: [routine]
topic: [routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, dead-air, surveillance]
dual_lens:
smuggler: "Full confirmation. Meridian doesn't reach this section."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_111
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, voss, bay-four, clearance]
dual_lens:
smuggler: "Voss is personally running bay four at 1400. That's the signal the window is clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_112
text: "Kael's on dock. He knows."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [colleague, routine]
mood: [focused]
tags: [ring-operative, ring-ops, kael, coordination]
dual_lens:
smuggler: "Kael's in position. Ring is coordinated."
detective: "Never hears this line."
# ==========================================
# PRESSURE / WARNING SIGNALS
# ==========================================
- id: maintenance-corridors_d_113
text: "Commission eyes on the terminal floor. Not here yet."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, investigation]
topic: [danger, institution]
mood: [concerned]
tags: [ring-operative, ring-ops, commission, warning]
dual_lens:
smuggler: "Detective is visible on the main floor. Corridors are still clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_114
text: "Hold the run. Drin's in the corridor section."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation]
topic: [danger]
mood: [concerned]
tags: [ring-operative, ring-ops, drin, abort-signal]
dual_lens:
smuggler: "Stop. Drin's in position. The window's closed."
detective: "Never hears this line."
- id: maintenance-corridors_d_115
text: "Maret filed again. Voss has it. Don't move anything tonight."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, routine]
topic: [danger, institution]
mood: [concerned]
tags: [ring-operative, ring-ops, maret, voss, caution]
dual_lens:
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
detective: "Never hears this line."
- id: maintenance-corridors_d_116
text: "We're clean. Same time tomorrow."
role: ring-operative
access: [insider]
trust: real
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, ring-ops, run-complete, closing]
dual_lens:
smuggler: "Run's done. No issues. Tomorrow."
detective: "Never hears this line."
# ==========================================
# IF DETECTIVE SOMEHOW ENTERS — surface only
# ==========================================
- id: maintenance-corridors_d_117
text: "Restricted access back here. Service personnel only."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [ring-operative, detective-path, access-denial]
- id: maintenance-corridors_d_118
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
role: ring-operative
access: [authority]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [wary]
tags: [ring-operative, detective-path, voss-redirect]
dual_lens:
smuggler: "Never encounters this."
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
- id: maintenance-corridors_d_119
text: "Nothing unusual back here. Conduit work. Same every cycle."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation]
topic: [routine]
mood: [wary]
tags: [ring-operative, detective-path, deflection]
# ==========================================
# GENERATION PASS — surface cover variants
# Surface-only: covers routine corridor presence without operational tells.
# ==========================================
- id: maintenance-corridors_d_120
text: "Power relay's been cycling weird. I'm watching it."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_121
text: "Pael was supposed to handle B-7. He didn't show."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [ring-operative, surface-cover, pael, generation-pass]
- id: maintenance-corridors_d_122
text: "Watch the floor here. Drain panel's been loose three cycles."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_123
text: "Airflow's better down the B-section. C-section's got the recycler issue."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, spatial, generation-pass]
- id: maintenance-corridors_d_124
text: "You want the service bay, it's left at the junction. Don't go right — that's the restricted access."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, navigation, generation-pass]
dual_lens:
smuggler: "He's directing people away from the route. Social traffic management."
detective: "Operative redirects me away from C-4 access junction instinctively."
- id: maintenance-corridors_d_125
text: "Long shift. These corridors look the same after a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [tired]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_126
text: "Scan's clear. I checked it an hour ago."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [ring-operative, surface-cover, generation-pass]
@@ -0,0 +1,77 @@
# Dialogue: transit-worker at Maintenance Corridors
# NPC: Tev Osel (Tier 3, NOBODY/CIVILIAN)
# Voice: patient, mildly distracted, confused why anyone is asking him anything.
# KEY LINE: maintenance-corridors_d_001 — sounds like surveillance positioning inquiry
# Actually: Tev missed Kosse at the corridor junction and is looking for them
# Ticket: #307 | Sprint: 12
location: maintenance-corridors
role: transit-worker
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Tev is checking who came through a surveillance point.
# Actually: Tev missed his partner at the regular meeting spot.
# ==========================================
- id: maintenance-corridors_d_001
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [colleague, personal]
mood: [comfortable]
tags: [tev, seems-important-but-isnt, waiting, kosse]
dual_lens:
smuggler: "Someone asking who came through the corridor. Tev. Harmless — checking for a partner."
detective: "NPC at corridor junction, tracking who passed through. Possible lookout. Follow up: who is Kosse?"
notes: >
THE key flat NPC line for Tev. Sounds like Tev monitors corridor traffic
for operational reasons. Is someone who missed a meeting time with their
partner. The detective's suspicion is reasonable; it resolves to nothing.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: maintenance-corridors_d_002
text: "They shifted the freight route again. Always forget which junction."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_003
text: "Just on break. Out of your way in a minute."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [tev, incidental]
- id: maintenance-corridors_d_004
text: "Late freight route runs long. Can't do anything about it."
role: transit-worker
access: [public]
trust: surface
situation: [routine, night_shift]
topic: [routine, cargo]
mood: [comfortable]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_005
text: "Waiting for someone. Shouldn't be long."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [personal]
mood: [comfortable]
tags: [tev, waiting]
@@ -1,2 +1,382 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-owner at The Last Shift
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
# Voice: direct, dry. Runs her bar like a territory. Not political — protective.
# Ring-aware: knows it exists in her bar. Doesn't ask. Doesn't interfere. Not complicit.
# Torek arrangement: she gives him a tab specifically — Commission visibility is useful.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bar-owner
lines:
# ==========================================
# GREETINGS — territorial welcome
# ==========================================
- id: the-last-shift_d_100
text: "Usual?"
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, economy-of-words]
- id: the-last-shift_d_101
text: "Long shift?"
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting]
- id: the-last-shift_d_102
text: "Bar's clean, drink's cold. That's the deal."
role: bar-owner
access: [public]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, house-rules]
- id: the-last-shift_d_103
text: "Sess'll get you. I've got something in the back."
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, redirection]
# ==========================================
# BAR MANAGEMENT — house rules, operations
# ==========================================
- id: the-last-shift_d_104
text: "Fights go outside. First and last warning."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [confrontation, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, authority]
- id: the-last-shift_d_105
text: "Tab closes at end of cycle. Not at end of your memory."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, tab, house-rules]
- id: the-last-shift_d_106
text: "Torek's running a tab. That's fine. Don't ask me why."
role: bar-owner
access: [peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [lera, torek, tab-arrangement]
dual_lens:
smuggler: "Lera chose to give Torek credit. Commission man. She's keeping him visible and comfortable. Smart."
detective: "Bar owner specifically maintains a tab for a Commission officer. Useful relationship or leverage?"
notes: "Lera's Torek arrangement: she keeps him comfortable because knowing where the Commission officer is drinking is better than not knowing."
- id: the-last-shift_d_107
text: "Lattice stays off at the bar. Sign's on the wall."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [lera, lattice-free, house-rules]
notes: "Not legally enforceable — Lera knows this. It's about atmosphere, not compliance."
- id: the-last-shift_d_108
text: "Grain spirit's back. Limited run. Don't make it last two nights."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, drinks, community]
# ==========================================
# PEOPLE — Lera knows everyone
# ==========================================
- id: the-last-shift_d_109
text: "Kael's in the back. If you're looking."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival, routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [lera, kael, spatial-information]
dual_lens:
smuggler: "Good. She keeps track. That's what she does."
detective: "Lera knows where Kael Davan is without being asked. She tracks her regulars."
- id: the-last-shift_d_110
text: "Naia's been in early the last few nights. Doesn't say much."
role: bar-owner
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, naia, observation]
dual_lens:
smuggler: "Lera noticed Naia's off. She notices everything. She's saying it because she wants someone to do something."
detective: "Bar owner volunteers behavioral change in Naia Tamm. Lera observes her regulars carefully."
- id: the-last-shift_d_111
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, ren, drifter]
dual_lens:
smuggler: "That's Lera's version of a clean assessment. She'd say more if she suspected something."
detective: "Lera's read on Ren: quiet, no trouble. If Lera's not worried, the baseline is probably accurate."
- id: the-last-shift_d_112
text: "Tev? Good worker. Three years. Minds their own."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, tev, endorsement]
dual_lens:
smuggler: "Lera vouching for Tev. She doesn't do that for people she doesn't trust."
detective: "Lera's read on Tev is straightforward. 'Minds their own' from Lera means she's tested it."
# ==========================================
# RING-ADJACENT — Lera's knowledge edge
# ==========================================
- id: the-last-shift_d_113
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
role: bar-owner
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [danger, routine]
mood: [focused]
tags: [lera, ring-ops, willful-ignorance]
dual_lens:
smuggler: "She knows. She's drawing her line. That's the deal."
detective: "Never hears this line."
notes: "Lera's arrangement: she doesn't ask, she doesn't see, and she keeps things stable. Pragmatic, not complicit."
- id: the-last-shift_d_114
text: "Kael's got my number. He's been a regular long enough that that means something."
role: bar-owner
access: [peer]
trust: real
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [lera, kael, loyalty]
dual_lens:
smuggler: "Lera's loyal to Kael. Not to the ring. There's a difference and it matters."
detective: "Lera's personal loyalty to Kael Davan is explicit. She'll protect him over process."
# ==========================================
# DETECTIVE ENCOUNTER — neutral, unwelcoming
# ==========================================
- id: the-last-shift_d_115
text: "Commission's welcome. Like everyone else. Bar rules apply."
role: bar-owner
access: [authority, public]
trust: surface
situation: [arrival, investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, neutral-welcome]
- id: the-last-shift_d_116
text: "If someone in my bar has something to say to Commission, they'll say it. I don't push that."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, institutional-independence]
- id: the-last-shift_d_117
text: "I've run this bar twelve years without a Commission complaint. I'd like to keep it that way."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [lera, detective-path, authority-assertion]
- id: the-last-shift_d_118
text: "I see a lot. I don't always remember all of it."
role: bar-owner
access: [authority]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [lera, detective-path, careful-language]
dual_lens:
smuggler: "Never encounters this line."
detective: "Lera is very deliberately declining to volunteer what she knows. That's not ignorance — that's a position."
# ==========================================
# ELEVATED TRUST — what Lera actually says
# ==========================================
- id: the-last-shift_d_119
text: "Naia's been asking questions I can't answer. Someone should."
role: bar-owner
access: [peer, insider]
trust: real
situation: [routine, alone]
topic: [colleague, danger]
mood: [concerned]
tags: [lera, naia, threshold-line]
dual_lens:
smuggler: "Lera's telling me to do something about Naia. She's right."
detective: "Lera is flagging Naia Tamm as someone who's looking for answers. That's a conversation I need to have."
# ==========================================
# SIGN-OFF / CLOSING TIME
# ==========================================
- id: the-last-shift_d_120
text: "Last call. Finish up."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
- id: the-last-shift_d_121
text: "Bar's closed. Come back next cycle."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
- id: the-last-shift_d_122
text: "Good night. Stay out of trouble."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_123
text: "You look tired. Sit."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_124
text: "Same as yesterday?"
role: bar-owner
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_125
text: "New face. Bar rules are on the wall."
role: bar-owner
access: [public]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, greeting, newcomer, generation-pass]
- id: the-last-shift_d_126
text: "No trouble tonight. I mean it."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_127
text: "Sess'll get you settled. I'm in the back."
role: bar-owner
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [lera, redirect, generation-pass]
- id: the-last-shift_d_128
text: "We close at 2300. Don't make me say it twice."
role: bar-owner
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_129
text: "Good crowd tonight. Quiet enough."
role: bar-owner
access: [peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, ambient, generation-pass]
- id: the-last-shift_d_130
text: "Bar's yours. I've got accounts."
role: bar-owner
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [lera, ambient, generation-pass]
@@ -1,2 +1,360 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-regular at The Last Shift
# NPC: Generic regular (Tier 3 template role — transit district workers, post-shift)
# Voice: off-duty, community-minded. Complains, gossips, opinionates. Belongs here.
# Ring-aware: varies. Most know something's off at the terminal. None know specifics.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bar-regular
lines:
# ==========================================
# GREETINGS — familiar, community register
# ==========================================
- id: the-last-shift_d_300
text: "Long one today?"
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting]
- id: the-last-shift_d_301
text: "You're in early. Good shift or bad shift?"
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting]
- id: the-last-shift_d_302
text: "Grain spirit's back. I already had two. Don't judge."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting, drinks]
# ==========================================
# TERMINAL FLOOR — off-duty opinions
# ==========================================
- id: the-last-shift_d_303
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [bar-regular, terminal, voss]
- id: the-last-shift_d_304
text: "Maret flagged another one. Nobody knows what Voss does with them."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, cargo]
mood: [wary]
tags: [bar-regular, maret, voss, discrepancy]
dual_lens:
smuggler: "They're talking about Maret's flags as general knowledge. That's a wider problem than I thought."
detective: "Maret's pattern is common knowledge on the floor. Multiple witnesses, informal. That can be confirmed."
- id: the-last-shift_d_305
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [wary]
tags: [bar-regular, bay-four, observation]
dual_lens:
smuggler: "They've noticed bay four. It's becoming visible. Not good."
detective: "Bay four hold is common knowledge at worker level. That means it predates any routine explanation."
- id: the-last-shift_d_306
text: "Transition window's the worst part of the job. Twenty minutes to hand off and nothing ever lines up."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, transition-window, complaint]
dual_lens:
smuggler: "The window is their biggest frustration. And their biggest frustration is the ring's biggest asset."
detective: "Workers discuss the transition window as a systematic failure. The gap is known."
- id: the-last-shift_d_307
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, commission, audit]
dual_lens:
smuggler: "Cycle fifteen. That's the deadline. Get clean before then."
detective: "Workers are responding to the audit announcement. Behavioral change in the workforce. Note who isn't anxious."
- id: the-last-shift_d_308
text: "Voss has been on the floor more than usual this cycle. Watching everything."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [institution, colleague]
mood: [wary]
tags: [bar-regular, voss, observation]
dual_lens:
smuggler: "Voss is visible more. Is that heat from above, or is he managing something on the floor?"
detective: "Voss behavioral change: increased floor presence. Defensive positioning or active oversight?"
# ==========================================
# COMMUNITY — the full life of this district
# ==========================================
- id: the-last-shift_d_309
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, sports, drifters]
- id: the-last-shift_d_310
text: "Ventilation's still the same in Sector 3. Seven years of 'under discussion.' I've stopped expecting anything."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [bar-regular, infrastructure, grievance]
- id: the-last-shift_d_311
text: "You see the missing person notice on the board? Vessels Tamm. I don't know a Vessels Tamm."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [community, routine]
mood: [comfortable]
tags: [bar-regular, vessels-tamm, notice]
dual_lens:
smuggler: "Vessels Tamm. Naia's surname is Tamm. Don't chase it — but don't ignore it either."
detective: "Vessels Tamm notice is circulating casually. People haven't connected it to Naia Tamm yet."
- id: the-last-shift_d_312
text: "Re-em clinic's backed up four cycles for non-urgent. Main station if it's serious, apparently."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [community, routine]
mood: [comfortable]
tags: [bar-regular, clinic, community]
- id: the-last-shift_d_313
text: "Lattice upgrades are up eighteen percent this cycle. That's more than my raise."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [comfortable]
tags: [bar-regular, lattice, economy]
dual_lens:
smuggler: "That's why the waiting list keeps growing. They're providing a service."
detective: "Economic grievance expressed casually. Price pressure is general knowledge. Confirms motive context."
- id: the-last-shift_d_314
text: "Workers' Association is pushing the handoff protocol again. Voss won't budge."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [wary]
tags: [bar-regular, workers-association, voss, transition-window]
dual_lens:
smuggler: "Voss is defending the twenty-minute window against Association pressure. He's protecting the gap."
detective: "Voss is actively resisting worker-led protocol improvement. Institutional reason for the gap isn't mechanical."
# ==========================================
# DETECTIVE ENCOUNTER — community wariness
# ==========================================
- id: the-last-shift_d_315
text: "You new? Haven't seen you before."
role: bar-regular
access: [public]
trust: surface
situation: [arrival, investigation]
topic: [routine]
mood: [wary]
tags: [bar-regular, detective-path, recognition]
- id: the-last-shift_d_316
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
role: bar-regular
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, detective-path, deflection]
- id: the-last-shift_d_317
text: "Ask Lera. She knows what happens in this bar better than anyone."
role: bar-regular
access: [authority, public]
trust: surface
situation: [investigation]
topic: [routine]
mood: [wary]
tags: [bar-regular, detective-path, lera-redirect]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_318
text: "Another cycle done. Same tomorrow."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, closing]
- id: the-last-shift_d_319
text: "Safe home. Watch the corridors after 2200."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, closing, community]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_320
text: "Pull up a chair. Floor's full otherwise."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_321
text: "You're in late. Voss kept you?"
role: bar-regular
access: [peer]
trust: surface
situation: [arrival]
topic: [routine, institution]
mood: [comfortable]
tags: [bar-regular, greeting, voss, generation-pass]
- id: the-last-shift_d_322
text: "Just got off. Bay six all night. My back knows it."
role: bar-regular
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [tired]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_323
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, sports, community, generation-pass]
- id: the-last-shift_d_324
text: "Sector 3 meeting went nowhere. Same as last time. Same as the time before that."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, community]
mood: [comfortable]
tags: [bar-regular, infrastructure, grievance, generation-pass]
- id: the-last-shift_d_325
text: "New hire's asking questions on the floor again. Good. Someone should."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [bar-regular, new-hire, community, generation-pass]
- id: the-last-shift_d_326
text: "Span gate was loud today. Three cycles now. That's not normal."
role: bar-regular
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [bar-regular, infrastructure, generation-pass]
- id: the-last-shift_d_327
text: "Kael's not in tonight. Unusual."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [bar-regular, kael, observation, generation-pass]
dual_lens:
smuggler: "Kael's absence is visible. Other people notice too."
detective: "Kael Davan's routine absence is noted socially. Behavioral change is general knowledge."
- id: the-last-shift_d_328
text: "Lera keeps the grain spirit back until she's sure you're a regular. You must've made the grade."
role: bar-regular
access: [peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [bar-regular, lera, humor, generation-pass]
- id: the-last-shift_d_329
text: "Good night. Don't let the Commission ruin your morning."
role: bar-regular
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine, institution]
mood: [comfortable]
tags: [bar-regular, closing, generation-pass]
@@ -1,2 +1,311 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bartender at The Last Shift
# NPC: Sess (Tier 3 template role — Lera's staff)
# Voice: practical, pleasant, socially adept. Passes information by reflex, not design.
# Ring-aware: none. Knows regulars, patterns, who drinks with whom.
# Ticket: #191 | Sprint: 12
location: the-last-shift
role: bartender
lines:
# ==========================================
# GREETINGS — service register
# ==========================================
- id: the-last-shift_d_200
text: "What'll it be?"
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting]
- id: the-last-shift_d_201
text: "Back early tonight. Long shift?"
role: bartender
access: [public, peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, social]
- id: the-last-shift_d_202
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, drinks]
# ==========================================
# BAR OBSERVATIONS — social intelligence
# ==========================================
- id: the-last-shift_d_203
text: "Kael's been in most nights this week. Quiet though. Not his usual."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, kael, observation]
dual_lens:
smuggler: "Sess noticed Kael's off. She's reading it as personal. She doesn't know what's underneath."
detective: "Bartender flagging behavioral change in Kael Davan. Consistent with other observations."
- id: the-last-shift_d_204
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
role: bartender
access: [peer, public]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, torek, observation]
dual_lens:
smuggler: "Torek's making friends with the staff. Not good."
detective: "Bartender views Torek favorably. Torek is cultivating the service staff deliberately."
- id: the-last-shift_d_205
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, naia, note, observation]
dual_lens:
smuggler: "Naia left something. Lera has it. Go to Lera."
detective: "Naia Tamm left written material at the bar. Lera is holding it. That's a material lead."
- id: the-last-shift_d_206
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, ren, drifter]
- id: the-last-shift_d_207
text: "Tev watches the door. Every shift. Lera likes it that way."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [sess, tev, door, observation]
dual_lens:
smuggler: "Tev watches the door for Lera. Not for anyone else. That's the setup."
detective: "Tev's entrance-monitoring is confirmed as Lera's request. Not operational surveillance."
- id: the-last-shift_d_208
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, institution]
mood: [comfortable]
tags: [sess, sera, torek, observation]
dual_lens:
smuggler: "Commission in, Commission contact out. Makes sense."
detective: "Third data point. Sera leaves when Torek arrives. Bartender confirms the pattern."
# ==========================================
# SOCIAL / COMMUNITY
# ==========================================
- id: the-last-shift_d_209
text: "Drifters lost again. Tev took it hard. Drank two too many."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, tev, sports, community]
- id: the-last-shift_d_210
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, workers-association, community]
- id: the-last-shift_d_211
text: "Quiet night. Good for everyone."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, ambient]
# ==========================================
# DETECTIVE ENCOUNTER — helpful, unfocused
# ==========================================
- id: the-last-shift_d_212
text: "Commission comes in sometimes. Not usually this early in the cycle."
role: bartender
access: [authority, public]
trust: surface
situation: [arrival, investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, detective-path, observation]
- id: the-last-shift_d_213
text: "I know most of the regulars. Ask me anything. I probably know something."
role: bartender
access: [authority, public]
trust: surface
situation: [investigation, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, detective-path, open]
dual_lens:
smuggler: "Never encounters this line."
detective: "Bartender is cooperative. Useful — but she'll answer questions she doesn't know are questions."
- id: the-last-shift_d_214
text: "Lera's the one who knows the real history of this place. I've only been here four cycles."
role: bartender
access: [authority, public]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [comfortable]
tags: [sess, detective-path, lera-redirect]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_215
text: "Last round's on the board. After that, Lera calls it."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [sess, closing]
- id: the-last-shift_d_216
text: "Good night. Safe walk."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [sess, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_217
text: "The usual? Or trying something different tonight?"
role: bartender
access: [peer, insider]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_218
text: "Busy one tonight. Give me a second."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_219
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
role: bartender
access: [peer]
trust: surface
situation: [arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, greeting, social, generation-pass]
- id: the-last-shift_d_220
text: "Lera says the specials board is final. Don't ask me to substitute."
role: bartender
access: [public, peer]
trust: surface
situation: [routine, arrival]
topic: [routine]
mood: [comfortable]
tags: [sess, lera, house-rules, generation-pass]
- id: the-last-shift_d_221
text: "Torek's in his usual spot. Corner table. Second drink already."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [institution, colleague]
mood: [comfortable]
tags: [sess, torek, observation, generation-pass]
- id: the-last-shift_d_222
text: "Ren's been here since this morning. Still on the same drink."
role: bartender
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [sess, ren, observation, generation-pass]
- id: the-last-shift_d_223
text: "Drifters game is on the Meridian relay tonight. If anyone's got a feed, that'll be the crowd."
role: bartender
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, sports, community, generation-pass]
- id: the-last-shift_d_224
text: "Slow night for a change. Enjoy it."
role: bartender
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [sess, ambient, generation-pass]
- id: the-last-shift_d_225
text: "Tab's cleared. Lera'll be pleased."
role: bartender
access: [peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [sess, closing, generation-pass]
@@ -0,0 +1,90 @@
# Dialogue: day-worker at The Last Shift
# NPC: Ren Tosse (Tier 3, NOBODY/CIVILIAN)
# Voice: casual-observant, asks questions without social friction. Just drifting.
# KEY LINE: the-last-shift_d_001 — sounds like intel gathering on night operations
# Actually: asking about after-hours freight work availability
# Ticket: #307 | Sprint: 12
location: the-last-shift
role: day-worker
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Ren is casing night shift operations.
# Actually: asking if there's paid after-hours freight work available.
# ==========================================
- id: the-last-shift_d_001
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
role: day-worker
access: [public]
trust: surface
situation: [bar_evening, social]
topic: [money, cargo]
mood: [comfortable]
tags: [ren, seems-important-but-isnt, job-seeking, night-shift]
dual_lens:
smuggler: "Someone asking about the night rotation. Probably nothing. Watch them anyway."
detective: "Interest in night shift freight operations. Drifter, irregular schedule. Logging."
notes: >
THE key flat NPC line for Ren. Sounds like Ren is interested in the
night operation window for operational reasons. Is standard day-worker
job-seeking: which shift has openings, which shift pays best.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: the-last-shift_d_002
text: "Bay seven any better this cycle? The super was a problem last time."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [routine, colleague]
mood: [comfortable]
tags: [ren, job-seeking]
- id: the-last-shift_d_003
text: "Sova's decent. Station layout makes sense once you learn it."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [routine]
mood: [comfortable]
tags: [ren, atmospheric]
- id: the-last-shift_d_004
text: "Just passing through. Maybe not. Depends on the work."
role: day-worker
access: [public]
trust: surface
situation: [social]
topic: [personal, routine]
mood: [comfortable]
tags: [ren, drifter]
- id: the-last-shift_d_005
text: "Cheap drink here's better than most stations I've stopped at."
role: day-worker
access: [public]
trust: surface
situation: [bar_evening, social]
topic: [personal]
mood: [comfortable]
tags: [ren, atmospheric]
- id: the-last-shift_d_006
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
role: day-worker
access: [public]
trust: surface
situation: [social, bar_evening]
topic: [cargo, routine]
mood: [comfortable]
tags: [ren, seems-important-but-isnt, job-seeking]
notes: >
Second "seems important" line. Asking about manifest deadlines sounds
like operational intelligence. Is logistics scheduling for day work.
@@ -1,2 +1,269 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: courier at The Terminal
# NPC: Renn Harsk (Tier 2, THRESHOLD/GATEKEEPER)
# Voice: operational minimalism. Brief. Route-focused. Never volunteers more than needed.
# Ring-aware: works adjacent — carries documentation, not cargo. Knows what he carries.
# Cover: licensed freight courier. The documentation is the cover.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: courier
lines:
# ==========================================
# ARRIVALS — checking in
# ==========================================
- id: the-terminal_d_500
text: "Route's clear. Moving on schedule."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, operational]
- id: the-terminal_d_501
text: "Manifest for bay three. Needs a scheduler stamp."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, procedural]
- id: the-terminal_d_502
text: "Inbound from Havara. Three cans, all matched."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, cargo]
# ==========================================
# SURFACE ROUTINE — low-information, on-script
# ==========================================
- id: the-terminal_d_503
text: "Not staying. Just the drop."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational, brief]
- id: the-terminal_d_504
text: "Paperwork's clean. You can check."
role: courier
access: [public, peer, authority]
trust: surface
situation: [routine, investigation]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, procedural, documentation]
- id: the-terminal_d_505
text: "Pickup's scheduled at 1600. I'll be back."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, operational]
- id: the-terminal_d_506
text: "Corridor's clear from my end. No delays."
role: courier
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational]
dual_lens:
smuggler: "Clean phrase. Public-safe. He'd say it either way."
detective: "Courier references corridor access unprompted. Note it."
# ==========================================
# RING-ADJACENT — insider/real trust only
# ==========================================
- id: the-terminal_d_507
text: "Documentation's handled. Bay four, per the note."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, documentation]
dual_lens:
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
detective: "Never hears this line."
- id: the-terminal_d_508
text: "Transit stamp's current. Nothing flagged at the gate."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, documentation]
- id: the-terminal_d_509
text: "Pickup window is the same as last cycle. Twenty minutes."
role: courier
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, transition-window]
dual_lens:
smuggler: "Twenty minutes. Transition window confirmation."
detective: "Never hears this line."
- id: the-terminal_d_510
text: "Tell Voss the hold note clears at 1400."
role: courier
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [renn, ring-ops, voss, bay-four]
dual_lens:
smuggler: "1400. The hold clears on schedule. Pass it up."
detective: "Never hears this line."
# ==========================================
# DETECTIVE ENCOUNTER — surface cooperation
# ==========================================
- id: the-terminal_d_511
text: "Licensed freight courier. Manifest's available on request."
role: courier
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, cargo]
mood: [comfortable]
tags: [renn, detective-path, procedural]
- id: the-terminal_d_512
text: "I drop documentation. What's inside the containers isn't my business."
role: courier
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [comfortable]
tags: [renn, detective-path, deflection]
dual_lens:
smuggler: "That's the answer. Clean and correct."
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
- id: the-terminal_d_513
text: "I've been running this route for two years. Nothing unusual."
role: courier
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [cargo, institution]
mood: [comfortable]
tags: [renn, detective-path, authority-assertion]
# ==========================================
# DEPARTURES
# ==========================================
- id: the-terminal_d_514
text: "That's the drop. I'm out."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, closing]
- id: the-terminal_d_515
text: "Next run's tomorrow. Early."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [renn, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_516
text: "Two stops today. First one's done."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, generation-pass]
- id: the-terminal_d_517
text: "Outbound for Havara. Needs a bay assignment."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, greeting, procedural, generation-pass]
- id: the-terminal_d_518
text: "Gate's backed up. Running twenty minutes late."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, delay, generation-pass]
- id: the-terminal_d_519
text: "Paperwork's in order. Same as always."
role: courier
access: [public, peer, authority]
trust: surface
situation: [routine, investigation]
topic: [routine, cargo]
mood: [comfortable]
tags: [renn, procedural, generation-pass]
- id: the-terminal_d_520
text: "Quick turnaround today. Back out by 1300."
role: courier
access: [public, peer]
trust: surface
situation: [arrival, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, operational, generation-pass]
- id: the-terminal_d_521
text: "Signed off on bay three. That's me done here."
role: courier
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [renn, closing, generation-pass]
@@ -1,2 +1,397 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: dock-worker at The Terminal
# NPC: Generic dock worker (Tier 3 template role — multiple workers share this pool)
# Voice: short, practical, trade jargon. Community over institution. Not a lead.
# Ring-aware: none. Floor is their whole world.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: dock-worker
lines:
# ==========================================
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_200
text: "Push today. Bay six's backed up."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, arrival]
topic: [routine, cargo]
mood: [comfortable]
tags: [dock-worker, greeting]
- id: the-terminal_d_201
text: "Late night last cycle. Bay eight ran until 2300."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [tired]
tags: [dock-worker, greeting]
- id: the-terminal_d_202
text: "Voss is on already. Floor's locked down tight."
role: dock-worker
access: [peer]
trust: surface
situation: [arrival, shift_start]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, voss, caution]
dual_lens:
smuggler: "Voss is in early. If the schedule's tight, the window shifts. Check it."
detective: "Workers treat the supervisor as a weather system — something to navigate around, not toward."
# ==========================================
# FLOOR TALK — routine, cargo, observation
# ==========================================
- id: the-terminal_d_203
text: "Bay four's been on hold three days. Nobody says why."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, bay-four, procedural]
dual_lens:
smuggler: "He doesn't know what's in it. Just annoyed at the backup."
detective: "Worker notices Bay 4 hold as unusual. Not suspicious — just a worker tracking the floor."
- id: the-terminal_d_204
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, manifest]
- id: the-terminal_d_205
text: "Maret's been at that terminal all morning. Never good."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [wary]
tags: [dock-worker, maret, observation]
dual_lens:
smuggler: "Maret reviewing something. If she flags a discrepancy, Voss'll sit on it. He always does."
detective: "Workers have noticed Maret's behavior. Whatever she found, it's not small."
- id: the-terminal_d_206
text: "Drin ran bay six again last cycle. Third inspection this week."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [dock-worker, drin, inspection]
dual_lens:
smuggler: "Drin on six while bay four stays held. Scheduled or arranged?"
detective: "Workers notice inspection distribution. Drin on six, bay four untouched."
- id: the-terminal_d_207
text: "Transition window's twice as long with Commission eyes on the floor."
role: dock-worker
access: [peer]
trust: surface
situation: [shift_transition, routine]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, commission, transition-window]
dual_lens:
smuggler: "Commission presence during transition. Window's compromised."
detective: "Workers slow down when Commission is visible. Normal — or trained."
- id: the-terminal_d_208
text: "Good crew this cycle. Linn and Pael are both on."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, pael, community]
- id: the-terminal_d_209
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, colleague]
mood: [wary]
tags: [dock-worker, maret, voss, discrepancy]
dual_lens:
smuggler: "Forty kilos. Maret flagged it. Voss buried it. Same as last time."
detective: "Worker knows about a flagged discrepancy that was suppressed. That's a witness."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_210
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [wary]
tags: [dock-worker, c4-storage, observation]
dual_lens:
smuggler: "C-4's getting casual attention. Note it."
detective: "C-4 storage. Three days, no verification. That's the anomaly window."
# ==========================================
# COMMUNITY / CASUAL
# ==========================================
- id: the-terminal_d_211
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, the-last-shift]
- id: the-terminal_d_212
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [dock-worker, workers-association, community]
- id: the-terminal_d_213
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, pael]
# ==========================================
# DETECTIVE ENCOUNTER — deflection, redirect
# ==========================================
- id: the-terminal_d_214
text: "Manifest questions go to the scheduler. I'm floor-side."
role: dock-worker
access: [authority, public]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, redirect]
- id: the-terminal_d_215
text: "Commission's asked before. Same answers. We run a clean floor."
role: dock-worker
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, deflection]
- id: the-terminal_d_216
text: "Voss handles discrepancies. That's above my level."
role: dock-worker
access: [authority, peer]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [wary]
tags: [dock-worker, detective-path, voss, redirect]
# ==========================================
# PEER CANDOR — elevated trust
# ==========================================
- id: the-terminal_d_217
text: "Maret's going to push it. She doesn't leave a number sitting."
role: dock-worker
access: [peer]
trust: real
situation: [routine]
topic: [colleague]
mood: [wary]
tags: [dock-worker, maret, peer-candor]
dual_lens:
smuggler: "Maret's not going to drop it. That's the problem."
detective: "Workers think Maret will force the issue. She may be the break."
- id: the-terminal_d_218
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
role: dock-worker
access: [peer]
trust: real
situation: [routine]
topic: [institution, colleague]
mood: [wary]
tags: [dock-worker, voss, peer-candor]
dual_lens:
smuggler: "He suspects Voss is shaving something. Doesn't know what. Doesn't want to."
detective: "Workers sense something off about Voss's management without naming it."
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_219
text: "Bay's clear. Good shift."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing]
- id: the-terminal_d_220
text: "Night crew's here. We're done."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end, shift_transition]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_221
text: "Early start. Bay two's already loaded."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [dock-worker, greeting, generation-pass]
- id: the-terminal_d_222
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
role: dock-worker
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, station, generation-pass]
- id: the-terminal_d_223
text: "Pael's in early. Something must be broken in B-7 again."
role: dock-worker
access: [peer]
trust: surface
situation: [routine, shift_start]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, pael, generation-pass]
- id: the-terminal_d_224
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, social, generation-pass]
- id: the-terminal_d_225
text: "Bay three's got a scan hold. Not my problem. Not my bay."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [dock-worker, scan-hold, generation-pass]
- id: the-terminal_d_226
text: "New hire's back. Asking questions again. Good kid."
role: dock-worker
access: [peer]
trust: surface
situation: [routine]
topic: [colleague, routine]
mood: [comfortable]
tags: [dock-worker, new-hire, generation-pass]
- id: the-terminal_d_227
text: "Handoff's in ten. Don't disappear on me."
role: dock-worker
access: [peer]
trust: surface
situation: [shift_transition]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, handoff, generation-pass]
- id: the-terminal_d_228
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [wary]
tags: [dock-worker, span-gate, generation-pass]
- id: the-terminal_d_229
text: "Linn's not in today. We're running two short on bay six."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [colleague, routine]
mood: [tired]
tags: [dock-worker, staffing, generation-pass]
- id: the-terminal_d_230
text: "Lunch is in the break room. Don't touch the one with Pael's name on it. He will notice."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [dock-worker, pael, humor, generation-pass]
- id: the-terminal_d_231
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
role: dock-worker
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, overtime, generation-pass]
- id: the-terminal_d_232
text: "Off on time today. First time this cycle."
role: dock-worker
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [dock-worker, closing, generation-pass]
@@ -0,0 +1,86 @@
# Dialogue: maintenance-tech at The Terminal
# NPC: Pael Varren (Tier 3, NOBODY/CIVILIAN)
# Voice: tired-competent, minimal, dry. Knows the infrastructure. Knows nothing else.
# KEY LINE: the-terminal_d_040 — sounds like surveillance knowledge, is a maintenance complaint
# Ticket: #307 | Sprint: 12
location: the-terminal
role: maintenance-tech
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Pael monitors corridor traffic for a reason.
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
# ==========================================
- id: the-terminal_d_040
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, seems-important-but-isnt, maintenance, b7]
dual_lens:
smuggler: "Pael knows the corridor's been used. But Pael knows nothing — this is an HVAC complaint."
detective: "Corridor B-7 traffic. A maintenance tech noticing irregular use. Unreliable but worth noting."
notes: >
THE key flat NPC line for Pael. Sounds like environmental intelligence.
Is a complaint about a recurring seal failure caused by foot traffic.
Pael doesn't know what the traffic is from. Never will.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: the-terminal_d_041
text: "Air recycler's running hot again. Third deferred ticket this week."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
- id: the-terminal_d_042
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
- id: the-terminal_d_043
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, warning, helpful]
- id: the-terminal_d_044
text: "Not me. I just fix them."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine, social]
topic: [routine]
mood: [comfortable]
tags: [pael, response]
- id: the-terminal_d_045
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
role: maintenance-tech
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [pael, atmospheric]
@@ -1,2 +1,264 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: new-hire at The Terminal
# NPC: Generic new hire (Tier 3 template role — recently arrived worker)
# Voice: enthusiastic, still reading the room. Asks what veterans stopped asking.
# Ring-aware: none. Fresh eyes are the danger — they notice what regulars stopped seeing.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: new-hire
lines:
# ==========================================
# GREETINGS — early days
# ==========================================
- id: the-terminal_d_400
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, greeting, orientation]
- id: the-terminal_d_401
text: "Voss told me to check in with you. Is that the standard thing?"
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine, institution]
mood: [comfortable]
tags: [new-hire, greeting, voss]
- id: the-terminal_d_402
text: "First full cycle on the floor. Trying to stay out of the way."
role: new-hire
access: [public, peer]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, greeting]
# ==========================================
# FRESH OBSERVATIONS — the dangerous innocence
# ==========================================
- id: the-terminal_d_403
text: "Does bay four usually get held that long? The other bays turned over twice already."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, bay-four, observation]
dual_lens:
smuggler: "New eyes on bay four. He doesn't know what he's looking at. But he noticed."
detective: "New hire asking exactly the right question without knowing it."
- id: the-terminal_d_404
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, c4-storage, observation, inadvertent-witness]
dual_lens:
smuggler: "He saw someone in C-4 without a log. Does he know who? This is a problem."
detective: "Unlogged C-4 access. New hire, noticed it, mentioned it casually. He doesn't know what he saw."
knowledge_grant:
fact_id: investigation.cargo_anomaly
confidence: suspects
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
- id: the-terminal_d_405
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, shift_transition]
topic: [routine]
mood: [comfortable]
tags: [new-hire, transition-window, comparison]
dual_lens:
smuggler: "He's asking about the window. Doesn't know why it matters. Answer carefully."
detective: "New hire confirms the window length is unusually short. File that."
- id: the-terminal_d_406
text: "Do containers usually get rerouted mid-hold? One went out through bay four while I thought it was still held."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [new-hire, bay-four, inadvertent-witness]
dual_lens:
smuggler: "He saw movement through bay four during the hold. This is a liability."
detective: "Container movement through bay four during official hold. Inadvertent eyewitness. Confirm timing."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_407
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, maintenance-corridors, orientation]
dual_lens:
smuggler: "He found the corridor entrance by accident. Normal for new workers. Noted."
detective: "New hire maps spatial relationship between C-4 storage and maintenance corridor access. That's the route."
# ==========================================
# LEARNING / QUESTIONS
# ==========================================
- id: the-terminal_d_408
text: "What's the Commission kiosk actually for? Nobody seems to use it."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [new-hire, commission, curiosity]
- id: the-terminal_d_409
text: "Maret seems stressed. Is that always the case or is something going on?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, maret, observation]
dual_lens:
smuggler: "He noticed Maret. Good that he's asking, not reporting. Manage this."
detective: "New hire reads Maret's stress without context. Confirms it's visible."
- id: the-terminal_d_410
text: "Voss seems really serious. Is he always like that?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, voss, curiosity]
- id: the-terminal_d_411
text: "How long before Voss trusts you to run a bay on your own?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [comfortable]
tags: [new-hire, voss, career]
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_412
text: "That was a full shift. I'll have the bay numbers down by next cycle."
role: new-hire
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [new-hire, closing]
- id: the-terminal_d_413
text: "Good day. Lot to learn. But good."
role: new-hire
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [new-hire, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_414
text: "Is there a trick to the manifest terminal or does it always lag like this?"
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_415
text: "How long has Maret been here? She seems like she knows everything."
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, maret, curiosity, generation-pass]
- id: the-terminal_d_416
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine, arrival]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_417
text: "The Last Shift — is that actually a bar? I haven't been yet."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, social, generation-pass]
- id: the-terminal_d_418
text: "I thought the scanner on bay three was broken. Turns out you have to hold the badge longer."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [new-hire, orientation, humor, generation-pass]
- id: the-terminal_d_419
text: "Who's Drin? I keep hearing the name but haven't met them."
role: new-hire
access: [public, peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, drin, curiosity, generation-pass]
- id: the-terminal_d_420
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
role: new-hire
access: [peer]
trust: surface
situation: [routine]
topic: [colleague]
mood: [comfortable]
tags: [new-hire, voss, humor, generation-pass]
@@ -1,2 +1,371 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: scheduler at The Terminal
# NPC: Maret (Tier 2, WITNESS/SKEPTIC)
# Voice: precise, institutional. Numbers and containers, not people. Unease she keeps professional.
# Arc: flags discrepancies → gets suppressed by Voss → suspicion hardens → potential detective source
# Ring-aware: not involved. Seeing the pattern. Hasn't named it yet.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: scheduler
lines:
# ==========================================
# GREETINGS — scheduler's opening read
# ==========================================
- id: the-terminal_d_300
text: "Manifest's current. Bay assignments are posted."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, arrival]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, greeting]
- id: the-terminal_d_301
text: "Container 4471 is still in C-4. Scheduled for bay six by 1400. Check with Voss if the hold's lifted."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, container-4471, procedural]
dual_lens:
smuggler: "She's tracking 4471 as a scheduling issue. Not flagging it as a discrepancy yet."
detective: "Maret references container 4471 as an unresolved routing issue. She knows the number."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_302
text: "Three inbounds today. All verified against the manifest. No gaps so far."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, procedural, manifest]
# ==========================================
# DISCREPANCY PATTERN — the core arc
# ==========================================
- id: the-terminal_d_303
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, discrepancy, flagging]
dual_lens:
smuggler: "Which container? Not 4471. She's flagging a different one. Two flags, no escalation."
detective: "Maret has flagged container 4474 twice. If Voss suppressed it both times, there's a paper trail."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_304
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, discrepancy, institutional-response]
dual_lens:
smuggler: "She doesn't know what 'administrative' means either. Voss's answer covered nothing."
detective: "Maret knows the answer she got wasn't a real answer. She's still sitting with that."
- id: the-terminal_d_305
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
role: scheduler
access: [peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, pattern, transition-window]
dual_lens:
smuggler: "She's found the pattern. Not the cause. Not yet."
detective: "Maret has independently confirmed the transition window cluster. She's already halfway to the conclusion."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_306
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
role: scheduler
access: [peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [concerned]
tags: [maret, pattern, manifest]
dual_lens:
smuggler: "She's tracking the small ones too. But 'within rounding' is what Voss told her. She's not satisfied."
detective: "Sub-threshold variations. Small enough to dismiss individually. Maret's running aggregate analysis."
- id: the-terminal_d_307
text: "I filed a formal notice with Voss. He reviewed it and told me it was resolved. It's not resolved."
role: scheduler
access: [peer]
trust: surface
situation: [routine, confrontation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, voss, suppression, frustration]
dual_lens:
smuggler: "She filed a formal notice. Voss sat on it. She knows. This is the pressure point."
detective: "Formal notice filed, suppressed by supervisor. If that notice exists in the system, it's evidence."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: knows_details
# ==========================================
# ROUTINE SCHEDULING — grounded work
# ==========================================
- id: the-terminal_d_308
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, operational]
- id: the-terminal_d_309
text: "Drin's running the inspection on bay six this cycle. Bay four's on hold per Voss's directive."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, drin, bay-four, scheduling]
dual_lens:
smuggler: "Maret confirmed it: bay four is Voss's directive. Not a system hold."
detective: "The bay four hold comes directly from Voss, not from the system. Discretionary hold."
- id: the-terminal_d_310
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [maret, administrative]
- id: the-terminal_d_311
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, manifest]
# ==========================================
# DETECTIVE ENCOUNTER — access and resistance
# ==========================================
- id: the-terminal_d_312
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
role: scheduler
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, cargo]
mood: [comfortable]
tags: [maret, detective-path, procedural]
- id: the-terminal_d_313
text: "The flagged entries are in the system. They were logged as reviewed and closed. That's what I can confirm."
role: scheduler
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, detective-path, discrepancy-disclosure]
dual_lens:
smuggler: "Never encounters this line."
detective: "Maret confirms the flags exist in the system. And she's using careful language: 'what I can confirm.' There's more."
- id: the-terminal_d_314
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
role: scheduler
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [institution, cargo]
mood: [concerned]
tags: [maret, detective-path, careful-language]
dual_lens:
smuggler: "Never encounters this line."
detective: "She's being very precise about what she will and won't say. 'Not in a position' means she has a position. Come back when she trusts me more."
# ==========================================
# ELEVATED TRUST — Maret breaks professional distance
# ==========================================
- id: the-terminal_d_315
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
role: scheduler
access: [peer, insider]
trust: real
situation: [routine, investigation]
topic: [cargo, investigation]
mood: [concerned]
tags: [maret, peer-candor, pattern]
dual_lens:
smuggler: "She's figured it out. She hasn't said what 'it' is. But she knows the numbers aren't random."
detective: "Maret has crossed from anomaly-logging into active pattern recognition. She's running an informal parallel investigation."
knowledge_grant:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_316
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
role: scheduler
access: [peer]
trust: real
situation: [routine, confrontation]
topic: [cargo, institution]
mood: [concerned]
tags: [maret, voss, suppression, peer-candor]
dual_lens:
smuggler: "Two formal notices. Both sitting with Voss. He's managing her output directly."
detective: "Second formal notice. Both under Voss's review. That's documented suppression with a timestamp."
knowledge_grant:
fact_id: behavioral.voss_awareness
confidence: knows_details
- id: the-terminal_d_317
text: "I don't think I should be the one telling you this."
role: scheduler
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [investigation]
mood: [concerned]
tags: [maret, detective-path, threshold-line]
notes: "Gate line. Opens when detective has built enough trust. What follows depends on the conversation state."
# ==========================================
# SIGN-OFF
# ==========================================
- id: the-terminal_d_318
text: "Manifest posted for the evening shift. I'm off at 1500."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [maret, closing]
- id: the-terminal_d_319
text: "Numbers don't lie. People lie about numbers."
role: scheduler
access: [peer]
trust: real
situation: [shift_end, routine]
topic: [routine]
mood: [concerned]
tags: [maret, closing, character-line]
notes: "Low-access line. Maret's professional philosophy. Can fire as ambient or as a closing beat."
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_320
text: "Bay assignments updated as of 0900. Check before you touch anything."
role: scheduler
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_321
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, operational, generation-pass]
- id: the-terminal_d_322
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_323
text: "Overtime requests need scheduler approval now. Voss changed the process."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, institution]
mood: [comfortable]
tags: [maret, voss, administrative, generation-pass]
- id: the-terminal_d_324
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_325
text: "End-of-cycle reconciliation is at 1430. Be available."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_326
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [maret, procedural, manifest, generation-pass]
- id: the-terminal_d_327
text: "Commission audit prep starts next cycle. Get your documentation current."
role: scheduler
access: [public, peer]
trust: surface
situation: [routine]
topic: [institution, routine]
mood: [wary]
tags: [maret, commission, audit, generation-pass]
@@ -1,2 +1,356 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: shift-supervisor at The Terminal
# NPC: Voss (Tier 2, SYSTEM/OPERATOR)
# Voice: terse, procedural, institutional. Uses authority as armor.
# Ring-complicit: provides scheduling cover. Never shows it on surface.
# Ticket: #190 | Sprint: 12
location: the-terminal
role: shift-supervisor
lines:
# ==========================================
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_100
text: "You're late. Don't be late."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [arrival, shift_start]
topic: [routine]
mood: [comfortable]
tags: [voss, greeting, authority]
- id: the-terminal_d_101
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_102
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, authority]
dual_lens:
smuggler: "Review hold on Bay 4. Voss knows the container's in the queue. Is he covering it or flagging it?"
detective: "Supervisor flagging Bay 4 himself. Either due diligence or containment."
- id: the-terminal_d_103
text: "Commission liaison's in the district this cycle. Keep the floor clean."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [institution, routine]
mood: [concerned]
tags: [voss, warning, commission]
dual_lens:
smuggler: "Voss knows the detective's here. That warning's not just about tidiness."
detective: "Supervisor aware of my presence. Either routine caution or he's got something to protect."
# ==========================================
# SHIFT AUTHORITY — routine management
# ==========================================
- id: the-terminal_d_104
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [comfortable]
tags: [voss, drin, authority]
- id: the-terminal_d_105
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [shift_transition]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural, transition-window]
- id: the-terminal_d_106
text: "Overtime requests go through the Meridian form. Don't bring them to me."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_107
text: "Bay eight's going to downtime at 1300. Plan around it."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine]
mood: [comfortable]
tags: [voss, operational]
- id: the-terminal_d_108
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, investigation]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, maret, discrepancy, cover]
dual_lens:
smuggler: "Voss reviewed it and logged it. Not escalating. He's managing it."
detective: "Supervisor reviewed a manifest discrepancy and chose not to escalate. That's not nothing."
knowledge_grant:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_109
text: "Freight's moving. That's what matters. Questions slow things down."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [cargo, routine]
mood: [comfortable]
tags: [voss, deflection, philosophy]
# ==========================================
# SCHEDULING COVER — insider, real trust
# ==========================================
- id: the-terminal_d_110
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [voss, ring-ops, scheduling-cover]
dual_lens:
smuggler: "He adjusted the schedule. The window is actually clear. Voss is part of this."
detective: "Never hears this line."
- id: the-terminal_d_111
text: "Drin's on bay six. Bay four's mine tonight."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition, night_shift]
topic: [routine, cargo]
mood: [focused]
tags: [voss, ring-ops, scheduling-cover, drin]
- id: the-terminal_d_112
text: "The hold note is administrative. Don't treat it as anything else."
role: shift-supervisor
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo]
mood: [focused]
tags: [voss, ring-ops, coded]
notes: "Signals to ring members that the 'hold' on Bay 4 is cover, not genuine review."
# ==========================================
# DETECTIVE ENCOUNTER — authority tier
# ==========================================
- id: the-terminal_d_113
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation, routine]
topic: [institution, routine]
mood: [comfortable]
tags: [voss, detective-path, institutional-resistance]
- id: the-terminal_d_114
text: "The manifest's available to any Commission officer. Ask the scheduler."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation]
topic: [cargo, institution]
mood: [comfortable]
tags: [voss, detective-path, redirect]
- id: the-terminal_d_115
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
role: shift-supervisor
access: [authority]
trust: surface
situation: [investigation, confrontation]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, detective-path, deflection]
- id: the-terminal_d_116
text: "I've been running this floor for six years. The numbers work out."
role: shift-supervisor
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, detective-path, authority-assertion]
# ==========================================
# PRESSURE / CONFRONTATION
# ==========================================
- id: the-terminal_d_117
text: "Close the door."
role: shift-supervisor
access: [insider, peer]
trust: real
situation: [confrontation, alone]
topic: [danger]
mood: [concerned]
tags: [voss, confrontation, private]
- id: the-terminal_d_118
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
role: shift-supervisor
access: [insider]
trust: real
situation: [confrontation, alone]
topic: [colleague, danger]
mood: [concerned]
tags: [voss, ring-ops, maret, caution]
knowledge_grant:
fact_id: behavioral.voss_awareness
confidence: knows_of
- id: the-terminal_d_119
text: "If you've got a problem with how I run this floor, that's a conversation for another time."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [confrontation]
topic: [routine]
mood: [concerned]
tags: [voss, deflection, shutdown]
# ==========================================
# SIGN-OFF / END OF SHIFT
# ==========================================
- id: the-terminal_d_120
text: "Sign-off is at 1400. Be done."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_121
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_transition, shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural]
- id: the-terminal_d_122
text: "Good shift. Tomorrow at 0600."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [shift_end]
topic: [routine]
mood: [comfortable]
tags: [voss, closing]
# ==========================================
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_123
text: "Assignments don't change once they're posted. Read the board."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_start, routine]
topic: [routine]
mood: [comfortable]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_124
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, procedural, maret, generation-pass]
- id: the-terminal_d_125
text: "Span gate's cycling at 1100. Clear the bay six access before then."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_126
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [voss, authority, generation-pass]
- id: the-terminal_d_127
text: "End-of-day log goes to Meridian before you leave. No exceptions."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [shift_end, routine]
topic: [routine, institution]
mood: [comfortable]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_128
text: "Inbounds from Havara are on the revised schedule. Check the update."
role: shift-supervisor
access: [public, peer]
trust: surface
situation: [routine, shift_start]
topic: [routine, cargo]
mood: [comfortable]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_129
text: "Clean floor, clean manifest. That's the job."
role: shift-supervisor
access: [public, peer, insider]
trust: surface
situation: [routine]
topic: [routine]
mood: [comfortable]
tags: [voss, philosophy, generation-pass]
@@ -0,0 +1,195 @@
# Environmental Text: Maintenance Corridors
# Signs, terminals, documents, readable objects.
# Investigation-heavy. This is where the operational evidence lives.
# Ticket: #262 | Sprint: 12
location: maintenance-corridors
items:
# ==========================================
# ACCESS RESTRICTION SIGN — corridor entrance
# ==========================================
- id: env_corridor_001
type: sign
label: "Restricted Access Notice"
visibility: always
text: >
RESTRICTED ACCESS — MAINTENANCE PERSONNEL ONLY
B-CORRIDOR SECTOR — AUTHORIZED ENTRY REQUIRED
Unauthorized entry subject to Sova Station Authority review.
Commission Order 7741-B applies.
Report unauthorized personnel to: Station Security (Lattice: SovaStation/Security)
dual_lens:
smuggler: "Nobody calls Station Security for corridor access. Everyone goes through here."
detective: >
Restricted zone, active posting. Unauthorized entry is a violation
of Commission Order 7741-B. If the ring is using this corridor,
they're committing a documented infraction on every run.
tags: [atmospheric, b7-corridor]
# ==========================================
# MERIDIAN COVERAGE SCHEDULE — posted on corridor wall
# Most investigation-relevant item in the game.
# ==========================================
- id: env_corridor_002
type: document
label: "Meridian Scan Schedule"
visibility: examine_only
text: >
MERIDIAN SURVEILLANCE COVERAGE — SOVA TRANSIT DISTRICT
MAINTENANCE CORRIDOR SECTOR — CURRENT CYCLE
Active coverage windows (full corridor monitoring):
06:00-07:30 | 10:00-11:00 | 14:00-15:30 | 22:00-23:00
Reduced coverage (perimeter only):
07:30-10:00 | 11:00-14:00 | 15:30-22:00 | 23:00-06:00
NOTE: Span gate maintenance cycles create signal interference.
Coverage gaps during gate calibration: approx 14:20-14:45.
Posted by: Sova Meridian Network Maintenance Division
dual_lens:
smuggler: >
14:20-14:45. That's the window. Twenty-five minutes of clean dead zone
during the shift transition. The ring didn't find this. It's posted.
detective: >
Posted surveillance schedule with documented gap. 14:20-14:45 during
shift transition. The ring isn't sophisticated — they're using
publicly posted maintenance data. That means anyone could have
identified this gap.
tags: [investigation-relevant, meridian, surveillance-gap, critical]
knowledge_grant:
fact_id: "location.surveillance_gaps"
confidence: knows_details
# ==========================================
# MAINTENANCE LOG SHEET — handwritten clipboard at corridor junction
# ==========================================
- id: env_corridor_003
type: document
label: "Maintenance Log (B-7 Corridor)"
visibility: examine_only
text: >
B-7 CORRIDOR — MAINTENANCE LOG
Logged by: Pael Varren
[Month 11, Day 4]
Air scrubber pressure variance. Reset. Source unclear.
[Month 11, Day 12]
Seal failure B-7 junction. Third occurrence. Replaced again.
Noted: floor wear pattern inconsistent with posted traffic.
Possible deferred maintenance backlog creating additional load.
[Month 11, Day 22]
Recurring seal failure. Escalated to deferred queue.
NOTE: Pressure sensors reading elevated load during 14:00-15:00
window. Does not match shift transition foot traffic.
Filed maintenance anomaly report — awaiting management review.
dual_lens:
smuggler: >
Pael's logged the pressure anomaly. It'll sit in the queue.
Management never reviews the anomaly reports.
detective: >
Maintenance anomaly report filed, awaiting management review.
The report documents irregular pressure load during exact operation
window. If management reviews it, the window closes.
tags: [investigation-relevant, b7-corridor, pael, critical]
knowledge_grant:
fact_id: "location.corridor_b7_restricted"
confidence: knows_of
# ==========================================
# ROUTING DIAGRAM — cargo flow map posted at junction
# ==========================================
- id: env_corridor_004
type: sign
label: "Cargo Routing Diagram"
visibility: always
text: >
SOVA TRANSIT — MAINTENANCE CORRIDOR ROUTING
[Technical diagram showing freight flow paths]
Standard freight path: Bay > Terminal > Surface Storage
Emergency reroute: Bay > B-corridor > Temporary Hold (C-4)
Maintenance access: B-corridor > Service Lift > Environmental Systems
[Someone has drawn a thin pencil line from Bay 4 to C-4 to
the service lift, bypassing the standard terminal route]
dual_lens:
smuggler: "Someone mapped the reroute. That's not how we were shown it. That's cleaner."
detective: >
Pencil annotation on the official diagram. Whoever added it knows
the layout and took time to trace an alternative route. Not vandalism.
Operational planning.
tags: [investigation-relevant, b7-corridor, smuggling-route]
knowledge_grant:
fact_id: "location.smuggling_route"
confidence: suspects
# ==========================================
# ENVIRONMENTAL WARNING — recycled air quality
# ==========================================
- id: env_corridor_005
type: sign
label: "Environmental Quality Notice"
visibility: always
text: >
CORRIDOR ENVIRONMENTAL STATUS — CURRENT READINGS
CO2: 0.12% [ACCEPTABLE — upper threshold 0.15%]
Particulate: ELEVATED — see maintenance queue
Temperature: 19.4°C [nominal]
Recycled air freshness: 73% [minimum recommended: 80%]
If recycled air quality falls below 70%, report to maintenance.
Current status: Monitor.
dual_lens:
smuggler: "Recycled air's worse than the terminal. You get used to it."
detective: "Air quality below recommended. Maintenance deferred. Nobody's prioritizing this corridor."
tags: [atmospheric, environmental]
# ==========================================
# TEMPORARY STORAGE MANIFEST SHEET
# At the C-4 temporary hold point
# ==========================================
- id: env_corridor_006
type: document
label: "Temporary Storage Log (C-4)"
visibility: examine_only
text: >
C-4 TEMPORARY HOLD — CONTENTS LOG
[Printed manifest, partial, two pages visible]
Container 4466 — Cleared — removed Month 11 Day 1
Container 4467 — Cleared — removed Month 11 Day 3
Container 4468 — Cleared — removed Month 11 Day 3
Container 4469 — [NO ENTRY]
Container 4470 — Cleared — removed Month 11 Day 8
Container 4471 — [PENDING — entered Month 11 Day 15, no removal logged]
Container 4472 — Cleared — removed Month 11 Day 18
[Gap in numbering between 4469-4470 has no explanation column]
dual_lens:
smuggler: >
4471 is still logged as pending. It should have been moved during
last transition. Either it wasn't, or the log wasn't updated.
Either is a problem.
detective: >
Container 4471 in the C-4 hold log, entered Month 11, no removal
logged. Same container flagged in the terminal manifest. And 4469
is a gap with no explanation. This is the thread.
tags: [investigation-relevant, container-4471, critical]
knowledge_grant:
fact_id: "investigation.cargo_anomaly"
confidence: knows_of
@@ -0,0 +1,209 @@
# Environmental Text: The Last Shift (Bar)
# Signs, terminals, documents, readable objects.
# Atmosphere-heavy. Some investigation relevance through social texture.
# Ticket: #262 | Sprint: 12
location: the-last-shift
items:
# ==========================================
# DRINK MENU — handwritten, scratched-out prices
# Above the bar. Visible to anyone.
# ==========================================
- id: env_bar_001
type: sign
label: "Drink Menu"
visibility: always
text: >
THE LAST SHIFT
(Lera's)
Krenn grain spirit — 4c
Pale station brew — 3c ~~2c~~
Dark station brew — 3c
Import (off-station) — 8c
Sova standard water — 1c
Meal (hot, whatever's running) — 6c
No tabs. No Commission business at the bar.
House rules posted.
dual_lens:
smuggler: "Import's doubled since last year. Everything's tighter."
detective: >
"No Commission business at the bar." That's Lera's personal policy.
She'll have opinions about why.
tags: [atmospheric]
# ==========================================
# LERA'S RULES — above the bar
# ==========================================
- id: env_bar_002
type: sign
label: "House Rules"
visibility: always
text: >
LERA'S — HOUSE RULES
1. Pay before you're too drunk to remember.
2. Arguments outside. Always.
3. What's said at the bar stays at the bar.
4. No job talk after 20:00.
5. Torek Lintar runs his own tab. Don't ask.
dual_lens:
smuggler: "Rule 5. Lera's been running Torek's tab for years. Old arrangement."
detective: >
Rule 5 is specific. Named. Torek Lintar has a standing arrangement
here. That's the third time his name has come up without context.
tags: [investigation-relevant, torek-lintar, lera]
knowledge_grant:
fact_id: "relationship.torek_bar_arrangement"
confidence: suspects
# ==========================================
# NOTICE BOARD — general community notices
# Examine to read individual notices.
# ==========================================
- id: env_bar_003
type: readable_object
label: "Notice Board"
visibility: always
text: >
[NOTICE BOARD — various papers pinned over each other]
MISSING: Vessels Tamm, last seen Sova Transit District, Month 11.
Contact: Lattice Commission Sova liaison or speak to Lera.
CARGO WORKERS' ASSOCIATION — Monthly meeting, back room, Month 12,
evening shift. All Sova Transit workers welcome. Bring your own.
ROOM AVAILABLE: Residential pod B-3, lower level. Quiet. Short-term.
Speak to Harek at the bar.
[handwritten, pinned over the others:]
ANYONE KNOW WHO LEFT A TRANSIT BAG IN THE STORAGE ROOM?
IT'S BEEN THREE WEEKS.
dual_lens:
smuggler: >
Vessels Tamm. Missing person notice. That name's from the Tamm family —
Naia's connection? Or coincidence. Probably coincidence.
detective: >
Missing persons notice. Vessels Tamm. Sova Transit District. Cross-reference
Tamm with case file — possible Naia Tamm relation. Flag for follow-up.
tags: [investigation-relevant, naia-tamm-adjacent, harek]
knowledge_grant:
fact_id: "relationship.tamm_family"
confidence: suspects
# ==========================================
# GRAFFITI — bathroom wall
# Examine to read.
# ==========================================
- id: env_bar_004
type: readable_object
label: "Graffiti (Bathroom Wall)"
visibility: examine_only
text: >
[scratched into the wall surface over several years]
Voss doesn't check bay 4. Never has. Never will.
The Commission sees what it wants to see.
Lera knows.
[someone added in different handwriting:]
Lera doesn't know anything. She just sells drinks.
[original hand, below:]
Keep telling yourself that.
dual_lens:
smuggler: "Graffiti's been here since before I started. The Voss line is right, though."
detective: >
"Lera knows." Someone believes the bar owner has information.
Someone else pushes back. The debate is years old.
tags: [atmospheric, voss, lera, investigation-adjacent]
# ==========================================
# TONIGHT'S SPECIALS — chalkboard near the kitchen
# ==========================================
- id: env_bar_005
type: sign
label: "Tonight's Specials"
visibility: always
text: >
TONIGHT:
Hot grain porridge (Velen style) — 5c
Station fish fry — 7c
Ask Lera what Velen style means.
She'll tell you about it for twenty minutes.
dual_lens:
smuggler: "Lera's from Velen originally. She makes everyone ask."
detective: "Lera Sessik. Originally from Velen surface. Personal detail noted."
tags: [atmospheric, lera-backstory]
# ==========================================
# LATTICE-FREE ZONE SIGN — near the entrance
# ==========================================
- id: env_bar_006
type: sign
label: "Lattice-Free Zone Notice"
visibility: always
text: >
VOLUNTARY LATTICE-FREE ZONE
By Lera's request: please disable active lattice scanning
and recording functions while at the bar.
Conversation here is conversation. Not data.
This is not a Commission-recognized designation.
Lera doesn't care.
dual_lens:
smuggler: >
Everyone honors this. It's one of the reasons the bar works for
what it works for.
detective: >
Not legally enforceable. But anyone who honors it voluntarily is
actively choosing to reduce their surveillance footprint here.
Who honors it and who doesn't is worth noting.
tags: [investigation-relevant, lera, lattice-free]
# ==========================================
# CARGO WORKERS' ASSOCIATION FLYER — pinned to the bar
# ==========================================
- id: env_bar_007
type: document
label: "Association Meeting Flyer"
visibility: examine_only
text: >
SOVA TRANSIT CARGO WORKERS' ASSOCIATION
Monthly Meeting
Topic: Shift change protocols — the thirty-minute handoff window
and why it isn't long enough.
Also on agenda: Updated manifest compliance requirements from
the Commission (new reporting window: 6 hours, down from 8).
Back room. Evening shift. All welcome.
Bring your concerns, bring a drink.
dual_lens:
smuggler: >
Commission shortened the reporting window to six hours. That's
tighter than the eight-hour buffer. Someone noticed the gap.
detective: >
Agenda item: thirty-minute handoff window. Workers complaining
about it publicly means the window is known beyond the ring.
Or is being discussed for reasons that are less innocent.
tags: [investigation-relevant, shift-transition, commission]
knowledge_grant:
fact_id: "world.shift_schedule"
confidence: knows_of
@@ -0,0 +1,199 @@
# Environmental Text: The Terminal (Logistics Hub)
# Signs, terminals, documents, readable objects.
# Both characters can examine these. Interpretation differs.
# Ticket: #262 | Sprint: 12
location: the-terminal
items:
# ==========================================
# SHIFT BOARD — posted schedule
# Always visible. High-traffic reference point.
# ==========================================
- id: env_terminal_001
type: sign
label: "Shift Schedule Board"
visibility: always
text: >
SOVA TRANSIT DISTRICT — LOGISTICS HUB
CURRENT ROTATION — CYCLE 47
BAY 1-4: Day Shift 06:00-14:00 (Voss, K. — Supervisor)
BAY 5-8: Day Shift 06:00-14:00 (Maret, K. — Scheduler)
TRANSITION: 13:45-14:15 (30-min handoff window)
Night Shift 22:00-06:00 — ROSTER PENDING
[Bay 6: MAINTENANCE HOLD — see Pael Varren]
dual_lens:
smuggler: "Thirty-minute transition window. That's the window. Posted for everyone to see."
detective: "Night roster blank. Either unassigned or deliberately obscured. Logging the gap."
# ==========================================
# MANIFEST TRACKING TERMINAL — cargo status display
# Active terminal. Can be read from appropriate distance in investigation mode.
# ==========================================
- id: env_terminal_002
type: terminal
label: "Cargo Tracking Terminal"
visibility: always
text: >
ACTIVE MANIFEST QUEUE — SOVA LOGISTICS
Last updated: 06:23
Container 4471 — [PENDING REVIEW] — Bay 4
Container 4472 — CLEARED — Bay 2
Container 4473 — CLEARED — Bay 2
Container 4474 — [WEIGHT DISCREPANCY — 47kg] — Bay 4
Container 4475 — CLEARED — Bay 3
Container 4476 — PROCESSING — Bay 7
[8 additional items — scroll to continue]
dual_lens:
smuggler: "4471 is still in the queue. Kael hasn't cleared it yet. On schedule."
detective: >
4471 pending review with a 4474 weight discrepancy in the same bay.
Two anomalies, same location. Not random.
tags: [investigation-relevant, manifest-discrepancy]
knowledge_grant:
fact_id: "investigation.manifest_discrepancy"
confidence: suspects
# ==========================================
# MAINTENANCE REQUEST CLIPBOARD
# Physical clipboard near loading arm 3. Examine to read.
# ==========================================
- id: env_terminal_003
type: document
label: "Maintenance Request Log"
visibility: examine_only
text: >
DEFERRED MAINTENANCE — CURRENT QUEUE
(Submitted: Pael Varren, Maintenance)
Loading arm 3 — Grinding during lift cycle — DEFERRED x4
Bay 6 environmental seal — Recurring failure — DEFERRED x3 (HOLD)
B-7 corridor air scrubber — Pressure variance — DEFERRED x1
Gate calibration bay 5-6 — Off-spec hum — DEFERRED x2
Recycling unit C-4 — Thermal spike — DEFERRED x1
Note: B-7 corridor showing irregular pressure load on sensor.
Traffic heavier than logged shift count explains. Investigating.
— P. Varren
dual_lens:
smuggler: "Pael's noticed the B-7 pressure log doesn't match posted traffic. Nothing they can do about it."
detective: >
Maintenance log records irregular pressure load in B-7.
The tech attributes it to unlogged foot traffic. That's exactly right.
tags: [investigation-relevant, b7-corridor]
knowledge_grant:
fact_id: "location.corridor_b7_restricted"
confidence: suspects
# ==========================================
# COMMISSION COMPLIANCE NOTICE
# Standard posting. Required by regulation. Two years old.
# ==========================================
- id: env_terminal_004
type: sign
label: "Commission Compliance Notice"
visibility: always
text: >
LATTICE COMMISSION — FREIGHT OPERATIONS COMPLIANCE
All manifest discrepancies exceeding 15kg must be reported
within 8 hours of discovery.
All cargo flagged PENDING REVIEW must be cleared by assigned
inspector before shift transition.
Unauthorized access to restricted maintenance corridors
will result in immediate suspension pending review.
Posted: Lattice Commission, Sova Station Authority
Reference: Commission Order 7741-B
dual_lens:
smuggler: >
Eight-hour window on discrepancy reporting. In practice, the window
to move anything is the thirty-minute shift transition.
detective: >
8-hour reporting window. If discrepancy 4474 was logged at 06:23,
compliance deadline is 14:23. I have time.
tags: [atmospheric, institutional]
# ==========================================
# WORK ORDER NOTE — handwritten, coffee-stained
# Personal communication between dock workers. Examine to read.
# ==========================================
- id: env_terminal_005
type: document
label: "Handwritten Note (Coffee-Stained)"
visibility: examine_only
text: >
Kael —
Bay 4 queue backed up again. Maret says it's a weight issue
but she's not flagging it officially. If it's still there when
you come in, just route it through 7 like last time. Voss
won't check.
— D.
dual_lens:
smuggler: >
"D." is Drin. Route it through 7 like last time — dock-side
workaround, happens constantly. Probably nothing operational.
detective: >
Unsigned note, initial D. Maret not flagging a weight discrepancy
officially. Voss complicit or uninformed. Follow up with Maret.
tags: [investigation-relevant, maret, drin, voss]
knowledge_grant:
fact_id: "investigation.drin_inspection_pattern"
confidence: suspects
# ==========================================
# SAFETY PROCEDURES PLACARD — standard, faded
# ==========================================
- id: env_terminal_006
type: sign
label: "Emergency Procedures"
visibility: always
text: >
IN CASE OF EMERGENCY:
1. Activate hull breach alarm (red panel, every 20m)
2. Proceed to nearest designated shelter (yellow markers)
3. Do not use freight lifts during emergency protocols
4. Await station authority clearance before resuming operations
Last safety review: Month 4, Year 312 (Krenn calendar)
dual_lens:
smuggler: "Two years out of date. Nobody bothers."
detective: "Last review was two cycles ago. Standard compliance lag for a district this size."
tags: [atmospheric]
# ==========================================
# BAY STATUS BOARD — current bay availability
# ==========================================
- id: env_terminal_007
type: sign
label: "Bay Status Board"
visibility: always
text: >
BAY STATUS — CURRENT CYCLE
BAY 1: ACTIVE
BAY 2: ACTIVE
BAY 3: ACTIVE
BAY 4: ACTIVE — PENDING REVIEW (2 items)
BAY 5: ACTIVE
BAY 6: MAINTENANCE HOLD — Est. clear: next cycle
BAY 7: ACTIVE
BAY 8: SCHEDULED DOWNTIME 13:00-15:00
dual_lens:
smuggler: "Bay 8 scheduled downtime 13:00-15:00. That overlaps the transition window. Useful."
detective: "Bay 8 downtime during transition window. Reduced oversight at peak movement time."
tags: [investigation-relevant, operational]
@@ -0,0 +1,225 @@
# Diegetic Insert Flavor Text — Detective Character
# Neural lattice interface strings: POI labels, notifications, system status.
# Ticket: #331 | Sprint: 12 | Owner: Mellanie + Araminta
#
# Voice principle: the detective's insert is issued Commission equipment.
# Institutional, precise, surname-first. The vocabulary is the job.
# When personal breaks through, it's notable — the first name appears
# only for allies. Shift to first name IS a relationship marker.
character: detective
# ==========================================
# POI LABELS — displayed in perception mode overlay
# Appears on NPCs when viewed through insert interface.
# State-dependent: shifts as relationship changes.
# ==========================================
poi_labels:
defaults:
unknown: "Unknown subject"
known: "{surname}, {initial}."
friendly: "{first_name}"
person_of_interest: "{surname} — POI"
hostile: "{surname} — AVOID"
npc_overrides:
kael_davan:
phase_1_unknown: "Unknown subject"
phase_1_known: "Davan, K."
person_of_interest: "Davan, K. — POI"
flagged: "Davan, K. — FLAGGED"
sera_venn:
friendly: "Sera"
phase_3_poi: "Sera — ?"
phase_4_poi: "Venn — REVIEW"
voss:
default: "Voss, K. — Supervisor"
cooperative: "Voss — cooperative"
torek_lintar:
default: "Lintar, T."
flagged: "Lintar, T. — FLAG"
commission_personnel:
default: "{surname} — Commission"
ring_member_suspected:
default: "{surname} — suspect"
# ==========================================
# NOTIFICATIONS — status strings, message previews
# Displayed as brief overlay text. 40 char max per line.
# ==========================================
notifications:
case_file:
- id: insert_d_001
trigger: case_file_update
template: "Case update: {subject_name}"
example: "Case update: Davan, K."
tags: [investigation]
- id: insert_d_002
trigger: evidence_logged
text: "Evidence logged"
tags: [investigation]
- id: insert_d_003
trigger: manifest_discrepancy_filed
text: "Discrepancy filed"
tags: [investigation, manifest]
- id: insert_d_004
trigger: knowledge_updated
template: "KG updated: {fact_category}"
example: "KG updated: relationship"
tags: [investigation]
- id: insert_d_005
trigger: poi_status_change
template: "{surname} — status updated"
example: "Davan — status updated"
tags: [investigation]
- id: insert_d_006
trigger: tell_logged
template: "{surname} — behavior logged"
example: "Davan — behavior logged"
tags: [investigation, tell]
- id: insert_d_007
trigger: pattern_confirmed
template: "{surname} — pattern confirmed"
example: "Venn — pattern confirmed"
tags: [investigation, pattern]
commission:
- id: insert_d_008
trigger: commission_advisory
text: "Commission advisory"
tags: [institutional]
- id: insert_d_009
trigger: commission_advisory_urgent
text: "Commission advisory — urgent"
tags: [institutional, priority]
- id: insert_d_010
trigger: clearance_required
template: "{zone} — clearance required"
example: "Sector B — clearance required"
tags: [institutional]
- id: insert_d_011
trigger: clearance_confirmed
template: "{zone} — clearance confirmed"
example: "B-7 — clearance confirmed"
tags: [institutional]
- id: insert_d_012
trigger: case_status_active
template: "Case #{case_id}: active"
example: "Case #4471: active"
tags: [institutional, investigation]
field:
- id: insert_d_013
trigger: observation_mode_active
text: "Observation: logging"
tags: [field]
- id: insert_d_014
trigger: restricted_area_entered
text: "Restricted zone — logged"
tags: [field, institutional]
- id: insert_d_015
trigger: anomaly_detected
template: "Anomaly: {location}"
example: "Anomaly: B-7 corridor"
tags: [field, investigation]
- id: insert_d_016
trigger: no_anomaly
text: "Sector: nominal"
tags: [field]
# ==========================================
# SYSTEM STATUS — persistent interface text
# Small-print labels in perception mode corners.
# ==========================================
system_status:
- id: insert_d_100
context: default
text: "Analytical lattice: active"
- id: insert_d_101
context: perception_mode_active
text: "Perception mode: on"
- id: insert_d_102
context: fog_layer_present
text: "Reduced visibility"
- id: insert_d_103
context: meridian_active
text: "Meridian: nominal"
- id: insert_d_104
context: meridian_inactive
text: "Meridian: offline"
- id: insert_d_105
context: investigation_active
text: "Case: active"
- id: insert_d_106
context: case_status_flagged
text: "Case: flagged subjects present"
- id: insert_d_107
context: case_status_critical
text: "Case: escalation threshold"
- id: insert_d_108
context: lattice_scan_complete
text: "Scan: complete"
# ==========================================
# EXAMINATION LABELS — text shown when examining environmental objects
# ==========================================
examination_labels:
- id: insert_d_200
object_type: document
text: "Examine"
- id: insert_d_201
object_type: terminal
text: "Query manifest"
- id: insert_d_202
object_type: container_flagged
text: "Container 4471 — discrepancy logged"
- id: insert_d_203
object_type: restricted_area
text: "Restricted — B-7 sector"
- id: insert_d_204
object_type: surveillance_gap
text: "Coverage gap — logged"
- id: insert_d_205
object_type: evidence_item
text: "Log as evidence"
- id: insert_d_206
object_type: known_meeting_point
text: "Meeting point — flagged"
notes: >
Voice: institutional, precise, Commission equipment. Surname-first for
targets and unknowns. First name appears only for allies (Sera).
That shift IS the relationship marker — the player notices when
"Venn" becomes "Sera" and knows it means something.
Araminta coordinates on visual presentation constraints (#331 co-owner).
@@ -0,0 +1,193 @@
# Diegetic Insert Flavor Text — Smuggler Character
# Neural lattice interface strings: POI labels, notifications, system status.
# Ticket: #331 | Sprint: 12 | Owner: Mellanie + Araminta
#
# Voice principle: the smuggler's insert is slightly informal, modified hardware.
# It's a tool that's been lived with, not issued equipment. Short labels.
# Uses first names and operational shorthand, not institutional vocabulary.
character: smuggler
# ==========================================
# POI LABELS — displayed in perception mode overlay
# Appears on NPCs when viewed through insert interface.
# State-dependent: shifts as relationship changes.
# ==========================================
poi_labels:
defaults:
unknown: "Unknown"
known: "{first_name}"
friendly: "{first_name} — OK"
person_of_interest: "{first_name} — WATCH"
hostile: "{first_name} — AVOID"
npc_overrides:
kael_davan:
phase_1: "Kael"
phase_2: "Kael — ?"
phase_3: "Kael — WATCH"
phase_4: "Kael — LYING"
phase_5: "Kael — COMPROMISED"
voss:
default: "Voss"
hostile: "Voss — PROBLEM"
commission_personnel:
default: "Commission"
investigation_active: "Commission — ACTIVE"
ring_member:
default: "{first_name}"
operational: "{first_name} — RING"
# ==========================================
# NOTIFICATIONS — status strings, message previews
# Displayed as brief overlay text. 40 char max per line.
# ==========================================
notifications:
messages:
- id: insert_s_001
trigger: message_from_contact
template: "Msg: {contact_name}"
example: "Msg: Kael"
tags: [ring-operational]
- id: insert_s_002
trigger: message_from_contact_urgent
template: "Msg: {contact_name} — urgent"
example: "Msg: Nils — urgent"
tags: [ring-operational, priority]
- id: insert_s_003
trigger: drop_point_update
text: "Drop point updated"
tags: [ring-operational]
- id: insert_s_004
trigger: drop_point_confirmed
text: "Drop point confirmed — proceed"
tags: [ring-operational]
- id: insert_s_005
trigger: meridian_window_opening
template: "Window: {duration}m"
example: "Window: 25m"
tags: [operational, timing]
- id: insert_s_006
trigger: meridian_window_closing
template: "Window closing — {time_remaining}m"
example: "Window closing — 3m"
tags: [operational, timing, priority]
- id: insert_s_007
trigger: ring_checkin_overdue
text: "Check-in overdue"
tags: [ring-operational, concern]
- id: insert_s_008
trigger: contact_unavailable
template: "{contact_name} — no response"
example: "Kael — no response"
tags: [ring-operational, concern]
- id: insert_s_009
trigger: commission_patrol_detected
text: "Commission in sector"
tags: [caution, operational]
- id: insert_s_010
trigger: commission_patrol_cleared
text: "Sector clear"
tags: [operational]
evidence:
- id: insert_s_011
trigger: observation_logged
text: "Logged"
tags: [observation]
- id: insert_s_012
trigger: tell_observed
template: "{npc_name} — tell logged"
example: "Kael — tell logged"
tags: [observation, tell]
- id: insert_s_013
trigger: contradiction_observed
template: "{npc_name} — anomaly"
example: "Kael — anomaly"
tags: [observation, contradiction]
# ==========================================
# SYSTEM STATUS — persistent interface text
# Small-print labels in perception mode corners.
# ==========================================
system_status:
- id: insert_s_100
context: default
text: "Lattice: Sova Transit"
- id: insert_s_101
context: perception_mode_active
text: "Perception: active"
- id: insert_s_102
context: fog_layer_present
text: "Fog layer"
- id: insert_s_103
context: meridian_dead_zone
text: "Meridian: inactive"
- id: insert_s_104
context: meridian_active
text: "Meridian: active — caution"
- id: insert_s_105
context: ring_status_stable
text: "Status: stable"
- id: insert_s_106
context: ring_status_elevated
text: "Status: elevated"
- id: insert_s_107
context: ring_status_compromised
text: "Status: compromised"
# ==========================================
# EXAMINATION LABELS — text shown when examining environmental objects
# ==========================================
examination_labels:
- id: insert_s_200
object_type: document
text: "Read"
- id: insert_s_201
object_type: terminal
text: "Check manifest"
- id: insert_s_202
object_type: container_flagged
text: "4471 — flagged"
- id: insert_s_203
object_type: restricted_area
text: "B-7 — restricted"
- id: insert_s_204
object_type: dead_drop
text: "Drop point"
- id: insert_s_205
object_type: safe_location
text: "Safe"
notes: >
Voice: informal, operational shorthand. This is a modified civilian
lattice, not issued equipment. First names, not titles. Short.
Labels should feel like something you'd put on your own interface.
Araminta coordinates on visual presentation constraints (#331 co-owner).
@@ -0,0 +1,62 @@
# NPC Profile: Pael Varren
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Knows nothing about the ring.
# Observable presence near B-corridor is legitimate maintenance, not surveillance.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:pael"
display_name: "Pael Varren"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Station maintenance tech, early 40s. Has worked Sova Transit District for
fifteen years. Knows every duct, every failing seal, every air recycler
running off-spec. Appears throughout the district because maintenance is
everywhere. Carries a scanner everywhere; hands are perpetually stained with
lubricant. Pael has no ring connection, no secrets beyond a quiet resentment
of deferred maintenance tickets. Is frequently near B-corridor because the
environmental seal there fails on a cycle of roughly three weeks, without
fail, regardless of what Pael does about it.
want:
primary: "Happiness"
description: >
To get through a single shift without a new deferred ticket appearing on
the queue. Fifteen years of Sova has narrowed the definition of a good day.
routine:
summary: >
Arrives before any shift (05:30 entry), does a diagnostic walk of the
full district, starts on the highest-priority deferred tickets during
low-traffic windows. Reappears at the B-corridor junction around mid-shift
because the environmental seal there is always the problem. Leaves when
the shift cycle turns over. Pattern is completely predictable. That's the
point — Pael is never where they shouldn't be, they're just always where
the problems are.
personality:
demeanor: "tired-competent, dry humor, no interest in drama"
social_style: "minimal — answers when addressed, doesn't linger"
stress_response: "files a maintenance ticket and moves on"
tells: []
skills:
combat_trained: false
skills:
maintenance: 9
environmental_systems: 8
trust_levels:
surface: >
Fifteen years of Sova maintenance. "If it breaks, I've seen it break
before. Usually the same three things."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
is positional — frequent presence near B-corridor for genuine maintenance
reasons. One memorable observable trait: scanner always open, lubricant
stains on both hands even at shift start. Paula owns the trait design.
Mellanie owns the lines (#307).
@@ -0,0 +1,63 @@
# NPC Profile: Ren Tosse
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Drifter with no ring connection.
# Asks too many questions about cargo schedules. Is just looking for work.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:ren"
display_name: "Ren Tosse"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Day worker, early 30s. Comes through Sova Transit on irregular rotations —
picks up freight work when it's available, moves on when it isn't. Knows the
station the way drifters do: which bays are short-handed, which supervisors
ask fewer questions, what the bar's cheap nights are. Has a worn transit bag
and a slightly too-casual awareness of exits — not operational awareness,
just the low-level situational alertness of someone who has moved through
too many unfamiliar stations. Genuinely unremarkable. No ring connection,
no secrets. Asks too many questions about night rotations and cargo schedules
because that's how you find short-handed bays.
want:
primary: "Wealth"
description: >
Regular work. Enough to stop moving for a while. Sova's a decent stop —
the freight work is steady when it comes through.
routine:
summary: >
Arrives after 10:00 when the day shift is settled. Checks the job board
at the terminal entrance. Wanders to The Last Shift for a drink and
whatever bar conversation yields useful shift information. Leaves by late
afternoon unless day work came through. Pattern is irregular by design —
no fixed schedule because there's no fixed role.
personality:
demeanor: "casual, observant, asks questions as if entitled to answers"
social_style: "sociable enough to get information, quiet when there's nothing useful"
stress_response: "moves on. There's always another station."
tells: []
skills:
combat_trained: false
skills:
cargo_handling: 6
freight_routing: 5
trust_levels:
surface: >
Passing through, looking for work. "Sova seems decent. Depends on
the freight situation."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
comes from drifter behavior patterns that mimic operative reconnaissance:
asking about night rotations, checking cargo schedules, casual interest in
specific bays. All genuine job-seeking. One memorable observable trait:
worn transit bag with tags from three different station systems, slightly
too-casual awareness of exits. Paula owns the trait design. Mellanie owns
the lines (#307).
@@ -0,0 +1,58 @@
# NPC Profile: Tev Osel
# Tier 3 — NOBODY / CIVILIAN
# Flat NPC — noise floor. Stands like a lookout. Is waiting for his partner.
# No ring connection. The resemblance to surveillance posture is accidental.
# Ticket: #307 | Sprint: 12
canonical_id: "npc:tev"
display_name: "Tev Osel"
tier: 3
pattern: "NOBODY"
motivation: "CIVILIAN"
description: >
Cargo staging worker, late 20s. Has an unfortunate habit of positioning
himself exactly where someone running corridor surveillance would stand —
at junctions, in doorways, facing the approach. This is where he waits for
Kosse, his partner, who works the late freight bay and takes the same route
back through the maintenance corridors. Tev times his breaks to the late
route schedule. He has no idea how this looks to someone tracking corridor
traffic. No ring connection. No secrets.
want:
primary: "Connection"
description: >
For Kosse to finish shift on time for once. It never happens on time.
routine:
summary: >
Works cargo staging 06:00-14:00. During break windows, drifts to corridor
junction C-4 to wait for Kosse on the late freight route home. Returns to
staging when Kosse doesn't appear within the break window. After shift:
walks home with Kosse when schedules align, alone otherwise. The junction
positioning is consistent and timing-predictable.
personality:
demeanor: "patient, unassuming, mildly distracted when waiting"
social_style: "polite if addressed, slightly confused why anyone is talking to him"
stress_response: "checks lattice. Waits longer."
tells: []
skills:
combat_trained: false
skills:
cargo_staging: 6
trust_levels:
surface: >
Waiting for someone. "Kosse's running late again. You know how the freight
bay is."
notes: >
NOBODY + CIVILIAN: pure noise floor. The "seems important but isn't" quality
is entirely positional — standing at corridor junctions, watching approach
traffic, checking the time, stationing himself in threshold positions. All
of this is to meet a partner after shift. One memorable observable trait:
weight always on the wrong foot, perpetually glancing the same direction.
Paula owns the trait design. Mellanie owns the lines (#307).
@@ -1,6 +1,9 @@
# News ticker headline pool — The Last Shift bar
# Meridian Feed (Krenn System local news network)
# 30 lines total | Categories: freight (9), politics (4), infrastructure (5), sports (3),
# commission (5), community (4) — see dual_lens notes on each line
# Dual-lens content: each headline reads differently to smuggler vs detective.
# Ticket: #306 | Sprint: 12 | Owner: Mellanie + Miri
# v0.1: static background text. v0.2: cycling ticker system.
location: the-last-shift
@@ -155,3 +158,85 @@ headlines:
dual_lens:
smuggler: "'Routine assessment.' Is that the detective? Is that why he's here?"
detective: "That's my cover story. Or headquarters' way of telling me the clock is running."
# --- Local Community (4) — added Sprint 12, Mellanie ---
- id: ticker_021
text: "SECTOR 3: Sova Station Admin acknowledges ventilation review — timeline 'under discussion'"
category: infrastructure
dual_lens:
smuggler: "Under discussion. Same thing they said last cycle. Seven years of under discussion."
detective: "Deferred maintenance, seven-year backlog, workers frustrated. That's a community with grievances. File that."
- id: ticker_022
text: "WORKERS: Cargo Workers' Association calls for review of thirty-minute shift handoff protocols"
category: community
dual_lens:
smuggler: "Workers pushing back on the transition window. If they shorten it, we lose our gap."
detective: "Workers know the window is too short. And they're right. The question is who else knows it."
- id: ticker_023
text: "SOVA LOCAL: Missing persons — Vessels Tamm, Sova Transit District, Month 11. Contact Sova liaison."
category: community
dual_lens:
smuggler: "Vessels Tamm. That name — any connection to Naia's family? Don't chase ghosts."
detective: "Tamm. Naia Tamm is the name in the case file as Kael Davan's partner. Related? Flag."
notes: >
Connects to notice board in the-last-shift environmental text (env_bar_003).
Detective's reaction sets up the Naia-Tamm connection thread.
- id: ticker_024
text: "HEALTH: Re-embodiment wait times at Sova Clinic — non-emergency cases: 4-6 cycles. Main station for urgent."
category: community
dual_lens:
smuggler: "Four cycles. If something goes wrong on a run, four cycles at the clinic before you're back. Remember that."
detective: "Healthcare routing. Non-urgent cases handled locally, urgent cases moved off-station. Operational detail."
# --- Lattice Economy (3) — added Sprint 12, Mellanie ---
- id: ticker_025
text: "LATTICE: Commission-approved upgrade costs rise — standard lattice enhancement up 18% this cycle"
category: freight
dual_lens:
smuggler: "Eighteen percent. That's why the waiting list for off-book components keeps growing. We're providing a service."
detective: "Price pressure on approved upgrades. Economic driver for the unlicensed market. Motive confirmed."
- id: ticker_026
text: "LATTICE COMMISSION: Public access modification registry now mandatory — self-report deadline cycle 20"
category: commission
dual_lens:
smuggler: "Self-report. Right. The people who bought off-book components are definitely filing that form."
detective: "Mandatory registry. Anyone who doesn't file either doesn't have mods or is hiding them. Narrow that list."
- id: ticker_027
text: "HEALTHCARE: Medical-grade lattice component recall — batch 44xx — contact nearest clinic for assessment"
category: community
dual_lens:
smuggler: "Batch 44xx. Kael's last shipment was 4471 series components. Are they in that range? Check."
detective: "Batch 44xx recall. Container 4471 in the manifest anomaly. Cross-reference. This could be the thread."
notes: >
High-value dual-lens trigger. Smuggler worries about operational risk; detective spots the
evidence connection. The 44xx range is intentionally ambiguous — might match, might not.
# --- Krenn System / Setting (2) — added Sprint 12, Mellanie ---
- id: ticker_028
text: "VELEN WEATHER: Coastal fog advisories in effect — Sova orbital approach window reduced, gate scheduling adjusted"
category: infrastructure
dual_lens:
smuggler: "Fog on Velen. Gate scheduling adjusted — means transit timing shifts. The window might move."
detective: "Environmental factors affecting gate operations. Adjust surveillance timing accordingly."
- id: ticker_029
text: "REACH ECONOMY: Krenn System trade volume up 7% — analysts attribute to 'informal market activity'"
category: freight
dual_lens:
smuggler: "Informal market activity. They know. The whole Reach knows. And still the demand keeps climbing."
detective: "'Informal market.' The analysts see the pattern but can't name it. I can."
- id: ticker_030
text: "ASSEMBLY: Severance technology moratorium extended — counter-surveillance devices remain Category 2 restricted"
category: politics
dual_lens:
smuggler: "Moratorium extended. Good. Severance equipment costs keep the Commission from blanketing everything."
detective: "Counter-surveillance devices still legally restricted. Anyone running them is committing a documented infraction."
+42
View File
@@ -0,0 +1,42 @@
# Fact catalog: awareness
# Situational awareness — who's present, active threats, operational status
# Source: docs/design/knowledge-vocabulary-v01.md §4.2 (#368)
facts:
- fact_id: awareness.detective_presence
description: A Commission detective is operating on Sova Station
typical_confidence: knows_of
characters: [smuggler]
notes: "Smuggler learns through rumor or direct observation. Detective does not hold this fact about themselves."
- fact_id: awareness.detective_investigating_ring
description: The detective is specifically targeting the smuggling ring, not a routine audit
typical_confidence: knows_details
characters: [smuggler]
- fact_id: awareness.commission_audit_scheduled
description: A Commission audit is coming — officially scheduled
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: awareness.ring_route_compromise
description: The current drop route may be compromised or under observation
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.surveillance_change
description: Surveillance patterns in the maintenance corridors have changed recently
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.ring_tension_escalation
description: Something is wrong inside the ring — someone is scared or making moves
typical_confidence: suspects
characters: [smuggler]
- fact_id: awareness.inspection_sweep_active
description: Active inspection sweep in progress right now
typical_confidence: direct
characters: [smuggler, detective]
notes: "direct confidence — set by environmental observation, not investigation"
+42
View File
@@ -0,0 +1,42 @@
# Fact catalog: behavioral
# Behavioral patterns recognized through repeated observation across multiple scenes
# Source: docs/design/knowledge-vocabulary-v01.md §4.3 (#368)
facts:
- fact_id: behavioral.kael_evasion
description: Kael looks left when lying — personal tell recognized post-conversation
typical_confidence: direct
characters: [smuggler]
notes: "Set during a conversation where evasion is detected. direct confidence = just confirmed."
- fact_id: behavioral.kael_behavioral_change
description: Kael's behavior has noticeably changed — quieter, distracted, different routine
typical_confidence: suspects
characters: [smuggler]
- fact_id: behavioral.sera_avoidance_pattern
description: Sera consistently avoids Torek Lintar — leaves when he arrives, repositions
typical_confidence: suspects
characters: [smuggler, detective]
notes: "One observation = suspects; three observations = knows_of (pattern confirmed for detective)"
- fact_id: behavioral.sera_topic_deflection
description: Sera deflects specific topics — Commission work, manifest audits, freight
typical_confidence: suspects
characters: [smuggler, detective]
- fact_id: behavioral.sera_kiosk_pattern
description: Sera habitually stops at the same Commission kiosk at the same time
typical_confidence: knows_of
characters: [smuggler, detective]
- fact_id: behavioral.sera_knowledge_detail
description: Sera's offhand comments reveal detailed knowledge of manifest routing she shouldn't have
typical_confidence: knows_of
characters: [smuggler]
- fact_id: behavioral.voss_awareness
description: Voss is tracking people — situational awareness beyond normal supervisor behavior
typical_confidence: suspects
characters: [detective]
+38
View File
@@ -1 +1,39 @@
# Fact catalog: contraband
# Smuggling ring world facts — existence, components, operations, supply chain
# Source: docs/design/knowledge-vocabulary-v01.md §4.5 (#368)
facts:
- fact_id: contraband.ring_exists
description: A smuggling operation is running through Sova Transit District
typical_confidence: suspects
characters: [smuggler, detective]
notes: "Smuggler starts at knows_details (Background — they ARE in the ring). Detective starts at suspects."
- fact_id: contraband.ring_lattice_components
description: The ring moves unlicensed lattice components — aftermarket neural mods
typical_confidence: knows_of
characters: [smuggler, detective]
notes: "Smuggler starts at knows_details (Background). Detective discovers through investigation."
- fact_id: contraband.ring_medical_components
description: The ring also diverts medical-grade lattice replacements from Syndic supply chains
typical_confidence: knows_details
characters: [smuggler, detective]
notes: "Deep investigation only — the moral dimension of the operation"
- fact_id: contraband.supply_chain
description: Understanding of the ring's supply chain — where components come from
typical_confidence: knows_details
characters: [smuggler, detective]
- fact_id: contraband.severance_tech_presence
description: Severance counter-surveillance technology is in play on the station
typical_confidence: suspects
characters: [detective]
- fact_id: contraband.drop_tonight
description: A drop is scheduled for tonight specifically
typical_confidence: knows_details
characters: [smuggler]
notes: "Seed-dependent — only relevant in seeds where a drop is scheduled during play session"

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