Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:42:35 +01:00
jpmschweitzerandClaude Opus 4.6 0beeb32ff7 chore(db): backup database after maintenance merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:41:54 +01:00
jpmschweitzer c2eb8fdf8f Merge remote-tracking branch 'origin/maintenance'
# Conflicts:
#	CLAUDE.md
2026-02-24 12:41:43 +01:00
jpmschweitzerandClaude Opus 4.6 ca51594195 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:44 +01:00
jpmschweitzerandClaude Opus 4.6 0ce20b9cd2 refactor(meta): compact CLAUDE.md from 188 to 67 lines
Move CLI reference material, endpoint configs, and orchestration
patterns to .claude/rules/ (auto-loaded). CLAUDE.md now focuses on
project identity, safety rules, and workflow conventions.

Update cross-references in ticket, docs-search, and git-commit
skills to point to new rules/ locations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:29 +01:00
jpmschweitzerandClaude Opus 4.6 465f2bdf98 feat(config): add .claude/rules/ for modular auto-loaded instructions
Introduces 5 rules files following Anthropic's recommended pattern
for splitting large CLAUDE.md files. Rules auto-load every session
and separate CLI reference material from core project identity.

- tea-cli.md: Gitea CLI reference, PR creation, protected branches
- git-safety.md: staging rules, commit conventions (from git-commit skill)
- project-structure.md: full annotated directory tree
- team-patterns.md: model selection, large content push pattern
- local-services.md: Gitea/Qdrant/Ollama endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:37:23 +01:00
jpmschweitzerandClaude Opus 4.6 ccbfa6bd72 chore(skills): add branch guard to pr-review skill
Reviews must run from main. Added step 0 hard stop if current branch
is not main, preventing repeated misuse from team branches.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:05:16 +01:00
jpmschweitzerandClaude Opus 4.6 ffaf635e9a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:56:54 +01:00
jpmschweitzerandClaude Opus 4.6 99a7841bf2 feat(config): add tea-comment wrapper for Gitea PR comments
Single-command wrapper that handles temp file creation and cleanup
so tea comment works reliably with multi-line strings. Pre-approved
in settings.json, referenced in CLAUDE.md and pr-review skill.

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

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

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

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

CHANGELOG: merged duplicate ### Changed sections.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:23 +01:00
jpmschweitzerandClaude Opus 4.6 5106358113 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 619fbbd9f3 docs(meta): document model selection and 1M context option
Add model selection section to CLAUDE.md covering /model sonnet[1m]
and opus[1m] for extended context sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:51 +01:00
jpmschweitzerandClaude Opus 4.6 c1c8626263 refactor(skills): lighten sprint-status context footprint
Delegate sprint-status to a haiku subagent (sprint-plan pattern) so
intermediate artifacts (sweep JSON, template read, PR list) stay out
of the main context window. Trim sweep JSON output by removing unused
fields (ok, sprint.status, priority, ticket_id) and shortening issue
detail strings. Condense output template by moving rendering rules
into SKILL.md and simplifying the bookkeeping table to two columns.

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:06:25 +01:00
jpmschweitzerandClaude Opus 4.6 381526ff74 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:52 +01:00
jpmschweitzerandClaude Opus 4.6 1627942627 feat(skills): add sprint-status cleanup sweep skill
Orchestrates sprint sweep + tea pr list to produce a consistent
health report: tickets by status, PR cross-reference, bookkeeping
issues, and open work by team summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:38 +01:00
jpmschweitzerandClaude Opus 4.6 2fb8e95d53 feat(db): add sprint sweep subcommand for health checks
Structured JSON output with tickets grouped by status, per-team
summary counts, and bookkeeping issue detection (unassigned
in_progress, stale backlog, assigned but done).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:35:33 +01:00
jpmschweitzerandClaude Opus 4.6 35a3fe20cb Merge remote-tracking branch 'origin/planning'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:17:20 +01:00
jpmschweitzerandClaude Opus 4.6 e91610862a chore(db): backup database after client and copy merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:15:29 +01:00
jpmschweitzer f163658ed1 Merge remote-tracking branch 'origin/client' 2026-02-24 02:15:16 +01:00
jpmschweitzerandClaude Opus 4.6 2001d89b6c chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:12:22 +01:00
jpmschweitzerandClaude Opus 4.6 0f1b951691 docs(docs): update Sprint 17 briefings with workshop results
Reconcile sprint planning after Knowledge Flow workshop:
- Server briefing: add #545-#551 workshop tickets, correct dependency
  chain, reference D-079–D-083, remove stale "blocked by workshop"
- Joint briefing: mark workshop complete, add completion proofs #9
  (contradiction) and #10 (NPC-to-NPC transfer), update teams table
- Copy briefing: add #552 contradiction monologue ticket with full
  Mellanie authoring spec per D-083 and Paula Round 2 output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:12:00 +01:00
jpmschweitzerandClaude Sonnet 4.6 f685cb7324 chore(meta): register D-079–D-083, close Q-024/Q-025/Q-026, ticket Sprint 17 knowledge flow work
Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24):

Decisions registered in decisions/perception.md:
- D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry)
- D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source)
- D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates)
- D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned)
- D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain)

Questions closed in decisions/questions.md:
- Q-024: closed by D-080 (immediate during conversation, not queued)
- Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3)
- Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct)

Tickets created (#545–#551, Sprint 17, team server):
- #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream)
- #546 KnowledgeGranted event + process_knowledge_events handler
- #547 ContradictionClaim struct + detection in observe_entity
- #548 NPC-to-NPC knowledge transfer system
- #549 tell_state.rs KG awareness — MVP information boundary
- #550 Contradiction monologue + event chain completion
- #551 DisclosureCandidates + unprompted disclosure

Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081).

Workshop source files committed: all round docs + workshop-outcomes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:59:58 +01:00
jpmschweitzer 0561387de9 Merge remote-tracking branch 'origin/copy' 2026-02-23 23:25:12 +01:00
jpmschweitzerandClaude Opus 4.6 abff69a632 chore(meta): Sprint 17 planning — briefings, workshop brief, close Q-025
- Write sprint 17 briefings for server, client, copy, visual, joint
- Add Knowledge Flow & NPC Boundaries workshop brief
- Close Q-025 (KG cap/eviction not needed at current scale)
- Assign 9 tickets to Sprint 17: Tell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:25:08 +01:00
jpmschweitzerandClaude Opus 4.6 0eafff89e0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:37:24 +01:00
jpmschweitzerandClaude Opus 4.6 414b8ff0ec docs(decisions): register Q-028 collision-resistant line IDs for auto-gen NPCs
NPC-scoped line ID scheme (D-035) works for hand-authored content but
will produce slug collisions with auto-generated populations (D-029).
Tracked as open question with ticket #544.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:37:12 +01:00
jpmschweitzerandClaude Opus 4.6 56ed2c8810 fix(content): PR #59 review — stale moods, orphaned IDs, tenure, fact_id
Address review comments from Hoshe, Paula, and Miri:
- Update mood vocabulary in 3 docs (line-pool-format.md §6.5,
  style-guide §11, content-directory-structure.md Appendix B) from
  pre-Sprint 14 values to current D-035 enum
- Fix worked example IDs in line-pool-format.md §3.5 to match
  actual the-last-shift kael-davan sequence (_015, _024, _026)
- Fix Section 5.1 restart note to describe multi-location continuity
- Fix style-guide §16 worked example: dock-worker_d_071 → kael-davan_d_076
- Fix stale mood reference in style-guide §16 Step 3
- Fix orphaned the-terminal_d_040 in maintenance-tech.yaml comment
- Fix orphaned the-terminal_d_008/018 in smuggler-inventory.yaml
- Fix Lera tenure: twelve → eighteen years (bar-owner_d_018)
- Fix fact_id: location.surveillance_gaps → investigation.surveillance_gaps
  in ring-operative.yaml (2 occurrences)
- Fix NPC name: Lera Osk → Lera Sessik in bar-owner.yaml comment
- Fix mood line format example in style-guide §5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:35:12 +01:00
jpmschweitzerandClaude Opus 4.6 0ebd417a1f test(client): add direction mapping tests for octant and entity facing (#540 review)
14 tests covering _octant_to_direction (all 8 octants + 2 fallbacks)
and _entity_direction (NPC default south, player facing 3 cases).
Closes review warning on zero test coverage for direction system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:35 +01:00
jpmschweitzerandClaude Opus 4.6 56b8381c40 fix(client): guard null texture and warn on unknown octant (#540 review)
Add push_error on null texture at create time, keep previous texture
on null at update time (entity stays visible mid-game). Add push_warning
on unrecognised octant in _octant_to_direction fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 892491dea2 chore(meta): update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:04:55 +01:00
jpmschweitzerandClaude Sonnet 4.6 c056ea1a1c feat(content): migrate line IDs to NPC-scoped namespace (D-035)
Line IDs in all dialogue and monologue pool files renamed from the
old location-scoped format (e.g. the-terminal_d_039) to the new
NPC-scoped format (e.g. kael-davan_d_001) per the D-035 Sprint 15
amendment.

Changes:
- 20 dialogue pool files across 3 locations renamed
- 14 monologue pool files (detective + smuggler) renamed
- Schema descriptions updated in dialogue/monologue schema files
- Authoring style guide and design docs updated with new examples
- Multi-location NPCs (kael-davan, pc-detective, pc-smuggler) given
  globally unique cross-file sequences to satisfy XREF uniqueness check

The location-scoped scheme already caused a collision (the-terminal_d_039
appearing in multiple NPC files) and would not scale to procedurally
generated NPC populations (D-029). Zero content changes — pure ID
substitution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:04:43 +01:00
jpmschweitzerandClaude Opus 4.6 f67f13ac95 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:48 +01:00
jpmschweitzerandClaude Opus 4.6 a0260176b4 feat(client): integrate D-019 angle sprites into entity renderer (#540)
Migrate entity rendering from ColorRect placeholders to Sprite2D with
rendered PNGs at -72.5° from horizontal. Key changes:
- Sprite2D.centered=false, scale=0.5 for 64px source → 32px runtime
- self_modulate for D-033 relationship tinting (modulate.a reserved
  for D-015 peripheral dimming)
- 8-octant to 4-cardinal direction mapping for sprite selection
- Feet-anchored ENTITY_OFFSET_Y for correct y-sort with tilted sprites
- Facing indicator repositioned to sprite local center (32,32)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:30 +01:00
jpmschweitzerandClaude Opus 4.6 ebc973b558 refactor(client): optimize zone_id extraction from O(N) to O(1) lookup (#543)
Build _tile_by_coord dictionary from member visible_tiles (covers both
test-mode "tiles" key and live-server "visible_tiles" key), then replace
the linear scan with a single dict lookup. Net-zero complexity: adds one
dict-set per tile in an existing iteration, removes the separate scan loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:01:22 +01:00
jpmschweitzerandClaude Opus 4.6 41e3b4a9b3 chore(db): backup database after server merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:35:46 +01:00
jpmschweitzer 459f0f5e66 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-23 20:35:33 +01:00
jpmschweitzerandClaude Opus 4.6 f331916869 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:32:12 +01:00
jpmschweitzerandClaude Opus 4.6 a4d5f0895b chore(skills): keep sprint team alive through PR review lifecycle
sprint-start: add step 9 (post-work lifecycle) — commit, push, review,
fix-comments loop, approve, then shutdown. Team stays alive until PR is
accepted. Step 8e updated to reference the new lifecycle.

pr-push: add step 9 (team awareness) — don't shut down agents after
pushing, suggest /pr-review next.

pr-review: add step 8 (post-review team actions) — cross-references
sprint-start step 9c for dispatching review comments to agents on
CHANGES_REQUESTED and shutdown on APPROVED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:31:47 +01:00
jpmschweitzer f2d006fddf Merge remote-tracking branch 'origin/server' 2026-02-23 20:25:39 +01:00
jpmschweitzerandClaude Opus 4.6 2189a9addd fix(simulation): address PR #56 review — pipeline extraction, ActiveDialogue, range check
Tyre #1: Extract run_dialogue_pipeline() shared helper — eliminates ~60
lines of duplication between process_talk_interaction and
process_dialogue_response (L1-L4 pipeline).

Hoshe #1: process_dialogue_response now updates ActiveDialogue with
current tick on follow-up selection — prevents stale started_tick.

Tyre #4: process_dialogue_response now updates InteractionMemory on
follow-up — multi-turn conversations are visible in history.

Hoshe #6 / Tyre #6: handle_dialogue_response adds server-side range
check (CLOSE_RANGE), matching Talk/Confront pattern (D-010 info
boundary).

Hoshe #2: Weighted selection fallback replaced with unreachable!() —
score_line always returns >= 1, so the fallback was dead code.

Hoshe #3: assert!(false, ...) → panic!() in serialization.rs (clippy).

Hoshe #4: SetFacing and TeleportToHub added to roundtrip test.

Hoshe #5: setup_dialogue_response_world inlined (trivial pass-through).

Tyre #2: Doc comment on DialogueCooldownTracker explains per-player-global
design choice (line IDs are NPC-scoped per D-035, no collision risk).

Tyre #3: CONFRONTATION_LINES comment updated with TODO for D-028/D-035
migration.

Tyre #5: DialogueResponse fixture added for cross-language GDScript
testing (input_dialogue_response.msgpack).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:20:00 +01:00
jpmschweitzerandClaude Opus 4.6 19f81d4887 fix(assets): PR #57 review — docs, uid, citation fixes
- Fix client sprite naming in README (files are _64.png, not .png)
- Clarify --path working directory (renderer/ from repo root)
- Remove /sprite-gen reference (skill not in branch yet)
- Add D-043 citation on outline color in render_export.gd
- Add uid to wall_bar_green.tscn for reproducible imports
- Note wall_bar_green has no rendered sprites yet
- Note npc_generic capsule symmetry is by design (D-044)
- Add outline + D-033 tint compatibility guidance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:17:47 +01:00
jpmschweitzerandClaude Opus 4.6 35594b7f14 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:02:04 +01:00
jpmschweitzerandClaude Opus 4.6 885ad21688 feat(assets): add test sprites for NPC and wall at D-019 angle (#541)
Runtime 64px sprites rendered through the new pipeline: generic NPC
(4 directions) and structural wall (4 directions). These unblock
client ticket #540 for sprite camera angle integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:49 +01:00
jpmschweitzerandClaude Opus 4.6 8217ebc0fb feat(assets): 3D sprite render pipeline — camera, lighting, docs (#541)
Update Camera3D to exact D-019 angle (-72.5° from horizontal).
Replace single DirectionalLight with three-point studio rig
(key 1.0, fill 0.4, rim 0.3) for clean silhouettes without
baked shadows. Add generic NPC capsule model (24×32px footprint
per D-044). Document full pipeline spec in renderer/README.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:43 +01:00
jpmschweitzerandClaude Opus 4.6 efa2dc543f feat(simulation): sprint 16 dialogue server — response handler, trust gossip, variety tracker
Move dialogue system registrations from BridgePlugin to NpcPlugin (#538):
game logic that depends on NPC-layer resources now registers where it
belongs. BridgePlugin retains only wire protocol concerns.

Implement DialogueResponse verb handler (#539): new process_dialogue_response
system runs the full D-028 four-layer pipeline to select follow-up lines
when the player picks a dialogue option. Clears ActiveDialogue when no
candidates remain. Fix latent schedule ambiguity — emit_observation_events
now has explicit .before(advance_tick) constraint.

Verify trust-gated gossip pipeline (#171): confirmed process_talk_interaction
correctly passes KnowledgeConfidence through relationship_to_trust() per
D-075. Added integration tests for Secret-tier access (Friendly+KnowsDetails)
and Surface-only fallback (Friendly+Suspects).

Wire DialogueCooldownTracker into selection (#338): added regression test
confirming no line_id repeats within the 600-tick cooldown window across
10 consecutive Talk interactions.

Closes #538, #539, #171, #338

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:01:39 +01:00
219 changed files with 26947 additions and 1376 deletions
+16
View File
@@ -0,0 +1,16 @@
# Git Safety
## Staging rules
- **Stage files by name** — never use `git add -A` or `git add .`
- Verify no secrets, saves, or binary blobs are staged
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills and agents belong in the repo
## Commit conventions
Use conventional commits: `<type>(<scope>): <summary>`
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
+8
View File
@@ -0,0 +1,8 @@
# Local Services
Endpoints are also preconfigured in `db/connectors/config.json`.
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
- **Qdrant:** `http://tower-of-joy:6333/`
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
+40
View File
@@ -0,0 +1,40 @@
# Project Structure (detailed)
```
client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
.config/ # Configuration files (linters, formatters, CI)
.cache/ # Local caches for testing/linting (gitignored)
docs/
discussions/ # Discussion rounds (archived here when complete)
briefings/ # Per-agent context briefings (maintained by Qatux)
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders
sprints/ # Sprint briefings per team
workshops/ # Workshop briefs and outputs
db/
schema.sql # Database schema
connectors/ # Connector scripts for SQLite and Qdrant
config.json # Endpoint configuration
ticket # Ticket CLI
sqlite_connector.py # SQLite mini MCP
qdrant_connector.py # Qdrant + ollama mini MCP
.claude/
agents/ # Agent personality files
skills/ # Skill definitions
rules/ # Auto-loaded instruction modules
decisions/ # Decision domain files (source of truth)
README.md # Domain index — use this to find specific D-records
architecture.md # Architecture decisions
perception.md # Perception and information system decisions
content.md # Content and narrative decisions
scope.md # Scope and feature decisions
process.md # Process and workflow decisions
questions.md # Open questions (Q-NNN)
rejected.md # Rejected proposals (R-NNN)
DECISIONS.md # Redirect to decisions/ directory
TEAM.md # Team roster and roles
```
+45
View File
@@ -0,0 +1,45 @@
# Gitea Access (tea CLI)
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
```bash
# List open PRs
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
# View a PR with comments
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
# Post a comment on a PR (or issue)
tooling/tea-comment <NUMBER> "comment body"
# Approve a PR
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
# List issues
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
## 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)
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
- **`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.
## Pull requests
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
Always provide all required flags to ensure non-interactive execution:
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(scope): short description" \
--description "PR body here" \
--base main \
--head branch-name
```
+33
View File
@@ -0,0 +1,33 @@
# Team Patterns
## Model selection
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
sprint planning, large reviews), switch to extended context on-demand:
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
- `/model opus[1m]` — Opus 4.6 with 1M context window
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
## Large content pushes
When producing many files (wiki pages, content batches, bulk docs):
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
## Team monitoring (stuck agent detection)
When leading a team (sprint, workshop, or any multi-agent session):
**Agent heartbeat rule** — include in every agent spawn prompt:
> If you have been working on a single task for more than 15 minutes
> without making progress, message the team lead with what is blocking
> you. Do not keep retrying the same approach silently.
**Team lead proactive checks:**
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
+1
View File
@@ -44,6 +44,7 @@
"Bash(make)",
"Bash(tea *)",
"Bash(tooling/tea-comment *)",
"Bash(chmod *)",
"Bash(ls *)",
+3 -3
View File
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
# Search Docs Skill
Semantic search across project documents. Basic commands (`qdrant-search`,
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
in CLAUDE.md. This skill covers advanced operations and workflows.
Semantic search across project documents. Endpoints are in
`.claude/rules/local-services.md`. This skill covers advanced operations
and workflows.
## Advanced Commands
+2 -4
View File
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
## Staging Rules
- Stage files by name — never use `git add -A` or `git add .`
- Verify no secrets, saves, or binary blobs are staged
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills belong in the repo
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
These apply to ALL git operations, not just this skill.
+9 -5
View File
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
```bash
cat > /tmp/pr-body.md << 'EOF'
## Summary
...
EOF
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "<title>" \
--description "$(cat /tmp/pr-body.md)" \
--description "## Summary ..." \
--base main \
--head <branch>
```
@@ -134,6 +130,14 @@ Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
### 9. Next steps
If a sprint team is active (you are the team lead), do NOT shut down
agents after pushing. The team should remain alive for PR review and
potential comment fixes.
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
## Arguments
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
+32 -15
View File
@@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow
### 1. Determine the branch
### 0. Branch guard — MUST be on `main`
If the user provided a branch name as argument, use it. Otherwise use the
current branch (`git branch --show-current`). If on `main`, ask the user
which branch to review.
```bash
git branch --show-current
```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run from the `main` worktree. Switch to `main` first."
Do NOT proceed with the review from a team branch.
### 1. Determine the branch to review
If the user provided a branch name as argument, use it. Otherwise list open
PRs and ask the user which branch to review.
To list open PRs on Gitea:
```bash
@@ -174,19 +183,11 @@ After presenting results to the user, post the review as a PR comment.
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)`:
Post using the `tea-comment` wrapper (handles temp files and cleanup):
```bash
tooling/tea-comment <PR_NUMBER> "review markdown here"
```
# 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)"
```
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
@@ -203,6 +204,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
Gitea does **not** auto-close PRs when you push a local merge — always close
manually with `tea pr close` after pushing.
### 8. Post-review team actions
If a sprint team is active and you are the team lead, handle the
review outcome:
**CHANGES_REQUESTED:**
The sprint-start lifecycle (step 9c) handles dispatching review
comments to agents. After presenting results, remind the lead:
"Review requested changes. Create tasks from the warnings/critical
issues and dispatch to idle agents, then re-push and re-review."
**APPROVED:**
The sprint-start lifecycle (step 9c) handles shutdown. After
presenting results, remind the lead: "Review approved. Proceed with
team shutdown per sprint-start step 9c."
## Tips from practice
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
+54
View File
@@ -325,3 +325,57 @@ Output to the user:
You are now the team lead. Agents work autonomously — monitor via
`TaskList`, communicate via `SendMessage`, and handle blockers as
they arise.
**When all tasks complete:** Do NOT shut down agents. The team stays
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
### 9. Post-work lifecycle
When all tasks are complete (TaskList shows all completed):
#### 9a. Commit and push
Run `/git-commit` to commit all changes, then `/pr-push` to create or
update the PR. Do NOT shut down agents — the team stays alive for review.
#### 9b. Review
Run `/pr-review` to spawn temporary reviewers. Wait for results.
#### 9c. Handle review outcome
**If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the
review output). Extract each warning/critical issue with:
- File path and approximate line
- Severity (critical / warning / suggestion)
- Description
2. Create a task per warning/critical issue:
```
TaskCreate(
subject: "Review: {short description}",
description: "{full issue description from review table, including
file path, severity, and reviewer name}",
activeForm: "Fixing review comment: {short description}"
)
```
Skip suggestion-severity items unless they are trivial (1-line fixes).
3. Dispatch to idle agents: send each a message via SendMessage telling
them to check TaskList for new review-fix tasks. Agents claim and
work tasks as usual.
4. After all review-fix tasks are complete, re-run `/git-commit` then
`/pr-push` to update the PR. Then re-run `/pr-review`.
5. Repeat this loop until review returns APPROVED.
**If APPROVED:**
1. Send `shutdown_request` to all sprint agents.
2. Wait for all `shutdown_response` confirmations.
3. Call `TeamDelete` to clean up.
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
merge on main."
+94
View File
@@ -0,0 +1,94 @@
---
name: sprint-status
description: >
Sprint health check and cleanup sweep. Lists all tickets in the active
sprint grouped by status, detects bookkeeping issues (stale tickets,
orphan PRs, done-but-open PRs, unassigned work), and shows open work
by team. Use when checking sprint progress, before sprint close, or
when housekeeping feels off. Triggers on "sprint status", "cleanup
sweep", "what's open", "sprint health".
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Sprint Status
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
When this skill is invoked, spawn a subagent using the Task tool:
```
Task(
subagent_type: "general-purpose",
model: "haiku",
prompt: "Run /sprint-status. Read the skill at
.claude/skills/sprint-status/SKILL.md for the full workflow
(below the --- separator), then execute it.",
description: "Sprint status report"
)
```
Present the subagent's output to the user verbatim. Do NOT run the
workflow yourself.
---
The remainder of this file is the subagent's reference for executing
the workflow.
## Step 1 — Gather data
Run these two commands in parallel:
```bash
db/connectors/sprint sweep
```
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
The `sweep` command returns JSON with:
- `sprint` — id, name, goal
- `progress` — total, done, pct
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
- `by_team` — per-team counts
- `issues` — bookkeeping problems with suggested fix commands
The `tea pr list` returns open PRs as `#N title` lines.
## Step 2 — Cross-reference PRs with tickets
Parse PR head branches from the `tea pr list` output. Known team branches:
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
Detect additional issues:
- **done_team_open_pr**: A team's tickets are all done but an open PR
still exists for that team branch.
- **orphan_pr**: An open PR exists on a branch that has no tickets in
the active sprint.
Add these to the issues list from step 1.
## Step 3 — Format output
Read `references/output-template.md` for the exact format spec.
Render the report using data from steps 1-2. Key rules:
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
- Sort tickets within sections by team then ticket ID
- Empty sections: show header with "(0)" and "(none)" — no empty table
- Bookkeeping Issues: two-column table (Issue, Fix)
- Open Work by Team: summary table at the bottom
- Issue type labels: `stale_backlog` → "Stale backlog",
`unassigned_in_progress` → "Unassigned in_progress",
`assigned_but_done` → "Assigned but done",
`done_team_open_pr` → "Done team with open PR",
`orphan_pr` → "Orphan PR"
## Step 4 — Suggest actions
After the formatted report, if there are bookkeeping issues, add a
"Suggested fixes" section with the fix command for each issue. Group
by issue type for readability.
@@ -0,0 +1,54 @@
# Sprint Status Output Template
## Sprint {N}: {Theme} — Status Report
**Goal:** {goal}
**Status:** {status} | {done}/{total} tickets ({pct}%)
**Open PRs:** {count} ({branches})
---
### Completed ({count})
| # | Team | Title | Assigned |
|---|------|-------|----------|
| #{id} | {team} | {title} | {assigned} |
### In Review ({count})
| # | Team | Title | PR |
|---|------|-------|----|
| #{id} | {team} | {title} | #{pr} |
### In Progress ({count})
| # | Team | Title | Assigned | Note |
|---|------|-------|----------|------|
| #{id} | {team} | {title} | {assigned} | |
### Blocked ({count})
| # | Team | Title | Blocked by |
|---|------|-------|------------|
| #{id} | {team} | {title} | #{ids} |
### Backlog ({count})
| # | Team | Title | Note |
|---|------|-------|----|
| #{id} | {team} | {title} | not started |
---
### Bookkeeping Issues
| Issue | Fix |
|-------|-----|
| {type}: {detail} | `{command}` |
### Open Work by Team
| Team | Backlog | In Progress | Review | Blocked | Done |
|------|---------|-------------|--------|---------|------|
| {team} | {n} | {n} | {n} | {n} | {n} |
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
+2 -3
View File
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
# Ticket Skill
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
This skill covers the full command reference.
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
section. This skill covers the full command reference.
## Commands
+1
View File
@@ -21,6 +21,7 @@ run_check() {
# --- Checks ---
run_check "tooling/check-fact-ids" "fact_id validation"
run_check "tooling/check-decision-ids" "decision ID duplication"
if [ "$ERRORS" -gt 0 ]; then
echo ""
+1
View File
@@ -43,3 +43,4 @@ Thumbs.db
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
.claude/plans/
.claude/projects/
.claude/agent-memory/
+93
View File
@@ -6,6 +6,99 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
### Changed
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
- Sprint 16 closed (8/8 done)
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
- DialogueResponse cross-language fixture for GDScript testing
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
- pr-push and pr-review skills updated with team lifecycle awareness
### Fixed
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
- Weighted selection fallback replaced with unreachable!() — dead code removed
- assert!(false) → panic!() in serialization tests (clippy)
- SetFacing and TeleportToHub added to roundtrip test coverage
## [v0.1.15] — 2026-02-23
### Added
+28 -141
View File
@@ -1,51 +1,26 @@
# The Settled Reach
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
**Official Title:** The Settled Reach (D-021)
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
**Official Title:** The Settled Reach
**Repository name:** settled-reach
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
## Project Structure
```
client/ # Godot 4 client (D-020)
server/ # Rust/bevy_ecs simulation server (D-020)
client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
.config/ # Configuration files (linters, formatters, CI)
.cache/ # Local caches for testing/linting (gitignored)
docs/
discussions/ # Discussion rounds (all rounds archived here per D-022)
briefings/ # Per-agent context briefings (maintained by Qatux)
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
db/
schema.sql # Database schema
connectors/ # Connector scripts for SQLite and Qdrant
config.json # Endpoint configuration
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
sqlite_connector.py # SQLite mini MCP
qdrant_connector.py # Qdrant + ollama mini MCP
.claude/
agents/ # Agent personality files
skills/ # Skill definitions
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, 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
questions.md # Q-001 through Q-011
rejected.md # R-001 through R-010
DECISIONS.md # Redirect to decisions/ directory
TEAM.md # Team roster and roles
docs/ # Architecture, design, briefings, sprints, workshops
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
.claude/ # Agents, skills, rules
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
```
Full annotated tree: `.claude/rules/project-structure.md`
## DevOps
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
@@ -54,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
### Worktree boundaries
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- Do not navigate above the git root unless explicitly instructed.
- **All work must remain within the git root** unless explicitly instructed otherwise.
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
### Database
@@ -74,110 +46,25 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
### Ticket and database access
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
```bash
db/connectors/ticket list --sprint 2 --team server
db/connectors/ticket show 78
db/connectors/ticket sprint --active
```
### CLI tools
### Sprint CLI
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
```bash
db/connectors/sprint status # Current sprint progress
db/connectors/sprint status --team server # Team-scoped view
db/connectors/sprint start-work --team client # Full context dump for starting work
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
db/connectors/sprint start # Activate a planned sprint
db/connectors/sprint stop # Complete an active sprint
```
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
```bash
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
```
### Qdrant / document search
```bash
db/connectors/qdrant-search "asymmetric information design"
db/connectors/qdrant-index docs/briefings/tyre.md
db/connectors/qdrant-health
db/connectors/qdrant-count
```
### Gitea access (tea CLI)
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
```bash
# List open PRs
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
# View a PR with comments
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
# Post a comment on a PR (or issue)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
# Approve a PR
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
# List issues
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
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.
| Tool | Command | Full reference |
|------|---------|----------------|
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
### Commit conventions
Use conventional commits with project-specific scopes:
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
### Pull requests
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
Always provide all required flags to ensure non-interactive execution:
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(scope): short description" \
--description "PR body here" \
--base main \
--head branch-name
```
### Large content pushes (team pattern)
When producing many files (wiki pages, content batches, bulk docs):
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
### Local services
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
- Qdrant: `http://tower-of-joy:6333/`
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
- Collection: `commonwealth` (768 dimensions, cosine distance)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+13
View File
@@ -125,6 +125,17 @@ knowledge_panel:
confidence_medium: "Likely"
confidence_low: "Unconfirmed"
confidence_rumor: "Hearsay"
# D-041 KnowledgeConfidence levels — displayed in journal panel
confidence_direct: "Confirmed"
confidence_knowsdetails: "Detailed"
confidence_knowsof: "Known"
confidence_suspects: "Unconfirmed"
# D-041 KnowledgeSource labels — displayed in journal panel
source_directobservation: "Observed"
source_toldby: "Told"
source_heard: "Overheard"
source_inferred: "Inferred"
source_background: "Prior"
# ============================================================
# TUTORIAL TEXT (DIEGETIC)
@@ -159,6 +170,8 @@ dialogue:
menu:
pause_title: "Paused"
resume: "Resume"
new_game: "New Game"
continue: "Continue"
settings: "Settings"
save_game: "Save"
load_game: "Load"
+7 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="The Settled Reach"
run/main_scene="res://scenes/main.tscn"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
SessionManager="*res://scripts/autoloads/session_manager.gd"
[audio]
@@ -125,6 +126,11 @@ debug_overlay={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
open_journal={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
]
}
teleport_hub={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+16 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -22,6 +22,9 @@
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
[node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
@@ -130,6 +136,15 @@ layer = 10
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
@@ -137,8 +152,6 @@ layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
; D-053: Stance indicator — top-right, color-coded
+66
View File
@@ -0,0 +1,66 @@
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
; Main menu — New Game / Continue / Quit.
; #258: D-085 per-game save directory created on New Game.
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_mainmenu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.05, 0.08, 1.0)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -120.0
offset_top = -80.0
offset_right = 120.0
offset_bottom = 100.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 16
alignment = 1
[node name="TitleLabel" type="Label" parent="VBox"]
layout_mode = 2
text = "THE SETTLED REACH"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 36
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
[node name="Spacer" type="Control" parent="VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 24)
[node name="NewGameBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "NEW GAME"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="ContinueBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "CONTINUE"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="QuitBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "QUIT"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
+49 -10
View File
@@ -4,6 +4,11 @@ extends Node
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
# Tiles use format: [{x, y, z, type}].
var current_snapshot: Dictionary = {}
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
# Set by SessionManager.new_game() or SessionManager.resume_game().
# Empty string when no session is active (main menu state).
var current_game_id: String = ""
var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
@@ -70,6 +75,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
var conversation_ended: Array = [] # [{speaker_id, target_id}]
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
# Populated from snapshot "poi_list" field — only updated when field present.
var discovered_pois: Array = []
# v14 fields (#174, #242): Character-filtered examine result.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var current_examine_result: Variant = null
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = []
@@ -240,16 +260,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
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
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
# Only update if the field is present — absence means "no change since last tick".
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
discovered_pois = snapshot.discovered_pois
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
discovered_pois = snapshot.poi_list
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
current_examine_result = snapshot.examine_result
else:
current_examine_result = null
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# Only update when field is present (null means no change, server sends when KG changes).
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
player_knowledge = snapshot.player_knowledge
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
+3
View File
@@ -19,6 +19,7 @@ enum Action {
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
}
@@ -106,6 +107,8 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("open_journal"):
action = Action.OPEN_JOURNAL
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
+117
View File
@@ -0,0 +1,117 @@
extends Node
## D-085 (#258): Game session lifecycle manager.
## Creates per-game save directories on New Game, resumes existing sessions,
## and handles quit-to-menu flow with save confirmation.
##
## All save dirs live under user://saves/<game-id>/ where game-id is
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
const SAVES_DIR := "user://saves/"
const GAME_SCENE := "res://scenes/main.tscn"
const MENU_SCENE := "res://scenes/main_menu.tscn"
var _quit_dialog: ConfirmationDialog = null
## Generate a new game-id, create its save directory, and activate the session.
## Returns the new game-id string.
func new_game() -> String:
var now := Time.get_datetime_dict_from_system()
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
now.year, now.month, now.day,
now.hour, now.minute, now.second,
]
var rng := RandomNumberGenerator.new()
rng.randomize()
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
var game_id := "%s-%s" % [timestamp, hex_seed]
var save_path := SAVES_DIR + game_id + "/"
var err := DirAccess.make_dir_recursive_absolute(save_path)
if err != OK:
push_error("SessionManager: failed to create save dir %s: %s" % [
save_path, error_string(err)])
GameState.current_game_id = game_id
return game_id
## Resume an existing game session by setting the active game-id.
func resume_game(game_id: String) -> void:
GameState.current_game_id = game_id
## List all game directories under user://saves/ sorted by last-modified (most recent first).
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
func list_game_dirs() -> Array:
var dir := DirAccess.open(SAVES_DIR)
if dir == null:
return []
var results: Array = []
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if dir.current_is_dir() and not entry.begins_with("."):
var dir_path := SAVES_DIR + entry + "/"
var newest_save := _find_newest_save(dir_path)
var mtime: int = 0
if newest_save != "":
mtime = FileAccess.get_modified_time(dir_path + newest_save)
results.append({
"game_id": entry,
"modified_time": mtime,
"newest_save": newest_save,
})
entry = dir.get_next()
dir.list_dir_end()
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.modified_time > b.modified_time)
return results
## Show "Save before quitting?" confirmation dialog, then return to main menu.
## node: the calling scene node (used as dialog parent).
## #554: The actual F5 save will be wired here once server supports SaveCommand.
func quit_to_menu(node: Node) -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
return # Dialog already open
_quit_dialog = ConfirmationDialog.new()
_quit_dialog.title = ""
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
node.add_child(_quit_dialog)
_quit_dialog.confirmed.connect(_do_quit_to_menu)
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
_quit_dialog.popup_centered()
func _do_quit_to_menu() -> void:
_cleanup_quit_dialog()
# #554: F5 quicksave will be triggered here before scene change when server
# supports SaveCommand. For now: navigate to menu without saving.
GameState.current_game_id = ""
get_tree().change_scene_to_file(MENU_SCENE)
func _cleanup_quit_dialog() -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
_quit_dialog.queue_free()
_quit_dialog = null
func _find_newest_save(dir_path: String) -> String:
var dir := DirAccess.open(dir_path)
if dir == null:
return ""
var best_name := ""
var best_time: int = 0
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if not dir.current_is_dir() and entry.ends_with(".sav"):
var mtime := FileAccess.get_modified_time(dir_path + entry)
if mtime > best_time:
best_time = mtime
best_name = entry
entry = dir.get_next()
dir.list_dir_end()
return best_name
+7 -2
View File
@@ -66,8 +66,13 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid := _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
+5
View File
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var clamped: int = clampi(time_of_day, 0, 1439)
return "%02d:%02d" % [clamped / 60, clamped % 60]
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
+52 -1
View File
@@ -14,6 +14,10 @@ extends Node2D
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@@ -98,13 +102,15 @@ func _process(delta: float) -> void:
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)
if minimap and minimap.has_method("set_insert_active"):
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list._hide()
interaction_list.hide_list()
else:
interaction_list.update_from_state()
@@ -131,6 +137,14 @@ func _process(delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #264: Update journal panel — auto-close on dialogue, refresh if open
if journal_panel and journal_panel.has_method("update_from_state"):
journal_panel.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
@@ -145,6 +159,9 @@ func _process(delta: float) -> void:
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# #174: Show examine result if server sent one this tick (#242)
_consume_examine_result()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -179,6 +196,10 @@ func _process(delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #264: J — client-only, toggle knowledge journal panel
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
@@ -313,6 +334,9 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens (cannot be open simultaneously)
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
@@ -451,6 +475,33 @@ func _teleport_transition() -> void:
tween.tween_callback(_flash_rect.queue_free)
# #174: Consume examine result — show overlay when server sends character-filtered observation.
# Clears after display (single-consume). Dismiss examine when dialogue opens.
func _consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
# Dismiss existing examine result if dialogue is active (focus priority)
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
# #264: Toggle journal panel. Called from input handler when J key pressed.
func _toggle_journal() -> void:
if not journal_panel:
return
# Journal and dialogue cannot be open simultaneously (sprint briefing)
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
+68 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 13
const PROTOCOL_VERSION: int = 14
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -206,6 +206,70 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"target_id": int(raw_end.get("target_id", 0)),
})
# v14: poi_list (#151) — discovered POIs for minimap rendering.
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
var poi_list: Array = []
var raw_pois: Variant = raw.get("poi_list")
if raw_pois is Array:
for raw_poi in raw_pois:
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
poi_list.append({
"poi_id": str(raw_poi["poi_id"]),
"name": str(raw_poi.get("name", "")),
"x": int(raw_poi["x"]),
"y": int(raw_poi["y"]),
"z": int(raw_poi.get("z", 0)),
"category": str(raw_poi.get("category", "Location")),
})
# v14: examine_result (#174, #242) — character-filtered observation text.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var examine_result: Variant = null
var raw_examine: Variant = raw.get("examine_result")
if raw_examine is Dictionary and raw_examine.has("text"):
examine_result = {
"entity_id": int(raw_examine.get("entity_id", 0)),
"text": str(raw_examine["text"]),
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
}
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
var raw_pk: Variant = raw.get("player_knowledge")
if raw_pk is Dictionary:
var kg_entities: Array = []
var raw_kg_entities: Variant = raw_pk.get("entities")
if raw_kg_entities is Array:
for raw_ke in raw_kg_entities:
if raw_ke is Dictionary and raw_ke.has("entity_id"):
kg_entities.append({
"entity_id": int(raw_ke["entity_id"]),
"name": str(raw_ke.get("name", "Unknown")),
"confidence": str(raw_ke.get("confidence", "Suspects")),
"source": str(raw_ke.get("source", "")),
"state": str(raw_ke.get("state", "Active")),
"relationship": str(raw_ke.get("relationship", "Unknown")),
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
})
var kg_facts: Array = []
var raw_kg_facts: Variant = raw_pk.get("facts")
if raw_kg_facts is Array:
for raw_kf in raw_kg_facts:
if raw_kf is Dictionary and raw_kf.has("fact_id"):
kg_facts.append({
"fact_id": str(raw_kf["fact_id"]),
"confidence": str(raw_kf.get("confidence", "Suspects")),
"source": str(raw_kf.get("source", "")),
"state": str(raw_kf.get("state", "Active")),
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
})
player_knowledge = {
"entities": kg_entities,
"facts": kg_facts,
}
return {
"tick": tick,
"entities": entities,
@@ -223,6 +287,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"pending_recognitions": pending_recognitions,
"conversation_events": conversation_events,
"conversation_ended": conversation_ended,
"poi_list": poi_list,
"examine_result": examine_result,
"player_knowledge": player_knowledge,
}
+94 -37
View File
@@ -2,7 +2,7 @@ class_name EntityRenderer
extends Node2D
# Entity renderer — manages entity sprites under the Entities node
# Creates/updates/removes ColorRect children based on entity data
# Creates/updates/removes Sprite2D children based on entity data
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
#
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
@@ -11,13 +11,15 @@ extends Node2D
#
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
# color from RelationshipState via the knowledge graph.
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
const TILE_SIZE: int = Constants.TILE_SIZE
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
const ENTITY_WIDTH: int = 24
const ENTITY_HEIGHT: int = 32
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
# Lerp speed — framerate-independent exponential smoothing.
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
@@ -27,7 +29,8 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
@@ -48,7 +51,7 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
@@ -57,8 +60,9 @@ func _process(delta: float) -> void:
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
if node_s:
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
@@ -92,15 +96,26 @@ func update_entities(entities: Array) -> void:
for entity_id in ids_to_remove:
_remove_entity_node(entity_id)
# Create a new entity node with D-033 color and optional facing indicator
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node = ColorRect.new()
entity_node.name = "Entity_" + str(entity_id)
entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT)
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node := Sprite2D.new()
entity_node.name = "Entity_" + str(entity_id)
# centered=false: top-left origin aligns with tile grid.
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
entity_node.centered = false
entity_node.scale = Vector2(0.5, 0.5)
# Load sprite for current facing direction
var direction := _entity_direction(entity_id, entity_data)
_entity_facing[entity_id] = direction
var tex := _load_sprite_texture(direction)
if tex == null:
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
entity_node.texture = tex
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
entity_node.self_modulate = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
@@ -121,12 +136,13 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
_update_entity_node(entity_id, entity_data)
# Update an existing entity node (target position, visibility dimming, facing)
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
var node = entity_nodes[entity_id]
# Update target position — the lerp in _process() will smoothly move there.
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
@@ -136,38 +152,42 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
# #540: Update sprite texture when facing direction changes
var new_dir := _entity_direction(entity_id, entity_data)
if new_dir != _entity_facing.get(entity_id, ""):
_entity_facing[entity_id] = new_dir
var new_tex := _load_sprite_texture(new_dir)
if new_tex != null:
(node as Sprite2D).texture = new_tex
# null: keep previous texture rather than going invisible mid-game
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"from": (node as Sprite2D).self_modulate,
"target": _color_for_kind(entity_data),
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
# D-015: Peripheral vision dimming via modulate.a.
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
var visibility: Variant = entity_data.get("visibility")
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
if not is_equal_approx(node.modulate.a, target_alpha):
node.modulate.a = target_alpha
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
var indicator = node.get_node_or_null("FacingIndicator")
if indicator != null:
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
if not entity_nodes.has(entity_id):
@@ -179,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
_entity_facing.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
return Constants.color_for_entity_kind(entity_data)
# Add a facing direction indicator triangle to the player entity
func _add_facing_indicator(parent_node: Control) -> void:
# #540: Map entity to current 4-direction sprite key.
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
if entity_id == GameState.player_entity_id:
return _octant_to_direction(GameState.player_facing)
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
return "south"
# Map 8-direction octant string to nearest 4-direction sprite key.
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
static func _octant_to_direction(octant: String) -> String:
match octant:
"North", "Northwest": return "north"
"Northeast", "East": return "east"
"Southeast", "South": return "south"
"Southwest", "West": return "west"
_:
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
return "south"
# Load the sprite texture for the given 4-direction key.
# Falls back to null with a push_warning if the asset is missing.
static func _load_sprite_texture(direction: String) -> Texture2D:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
if ResourceLoader.exists(path):
return load(path) as Texture2D
push_warning("EntityRenderer: sprite not found: %s" % path)
return null
# Add a facing direction indicator triangle to the player entity.
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
func _add_facing_indicator(parent_node: Node2D) -> void:
var indicator := Polygon2D.new()
indicator.name = "FacingIndicator"
var s := Constants.FACING_INDICATOR_SIZE
@@ -197,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
Vector2(s * 0.6, -offset + s * 0.4),
])
indicator.color = Constants.ENTITY_COLOR_PLAYER
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
# Indicator rotates around this point to track player facing direction.
indicator.position = Vector2(32.0, 32.0)
parent_node.add_child(indicator)
+353 -17
View File
@@ -1,5 +1,15 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
## #348: F3 debug overlay — real-time visualization of game state for dev use.
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
##
## Panels:
## 1. Stats text (top-left): tick, pos, fps, etc.
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
@@ -8,31 +18,141 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
const COL_GAP := 16
# World overlay colors
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
const TAG_TEXT_COLOR := Color("#c8d0e0")
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
const GRAPH_LINE_COLOR := Color("#6bc9a6")
const GRAPH_WARN_COLOR := Color("#e8c547")
# Vision cone geometry (radians)
# Forward: ±60° around facing direction (120° total)
# Peripheral: ±60° to ±120° on each side (60° band each side)
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
const CONE_ARC_STEPS: int = 20
# NPC path history
const NPC_HISTORY_LEN: int = 12
const NPC_DOT_RADIUS: float = 3.5
const PLAYER_DOT_RADIUS: float = 5.0
# Tick timing graph
const GRAPH_W: float = 160.0
const GRAPH_H: float = 48.0
const GRAPH_MARGIN: float = 10.0
const TICK_HISTORY_LEN: int = 30
const TICK_WARN_MS: float = 120.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _cached_font: Font = null
var _dev_mode: bool = false
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
var _npc_paths: Dictionary = {}
var _last_tick_processed: int = -1
# Tick timing ring
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
var _tick_deltas: Array = [] # ms between consecutive snapshots
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_dev_mode = OS.is_debug_build()
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if not _dev_mode:
return
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
if not _dev_mode or not visible:
return
# Record tick arrival time for timing graph
var now_ms := Time.get_ticks_msec()
if _last_tick_processed != GameState.current_tick:
_last_tick_processed = GameState.current_tick
if _tick_times.size() > 0:
_tick_deltas.append(float(now_ms - _tick_times.back()))
if _tick_deltas.size() > TICK_HISTORY_LEN:
_tick_deltas.pop_front()
_tick_times.append(now_ms)
if _tick_times.size() > TICK_HISTORY_LEN + 1:
_tick_times.pop_front()
_update_npc_paths()
queue_redraw()
func _update_npc_paths() -> void:
var seen_ids: Dictionary = {}
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
var kind_variant: String = entity.get("kind", {}).get("variant", "")
if kind_variant != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if eid < 0:
continue
seen_ids[eid] = true
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
if not _npc_paths.has(eid):
_npc_paths[eid] = []
var path: Array = _npc_paths[eid]
if path.size() == 0 or path.back() != pos:
path.append(pos)
if path.size() > NPC_HISTORY_LEN:
path.pop_front()
# Prune entities no longer visible
for eid in _npc_paths.keys():
if not seen_ids.has(eid):
_npc_paths.erase(eid)
# ---------------------------------------------------------------------------
# Draw dispatch
# ---------------------------------------------------------------------------
func _draw() -> void:
if not visible:
return
_draw_stats_panel()
_draw_world_overlays()
_draw_tick_graph()
# ---------------------------------------------------------------------------
# Panel 1: Stats text (top-left)
# ---------------------------------------------------------------------------
func _draw_stats_panel() -> void:
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
@@ -71,7 +191,10 @@ func _draw() -> void:
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var gid := GameState.current_game_id
left_lines.append(["game_id", gid if gid != "" else "-"])
right_lines.append(["npc_paths", str(_npc_paths.size())])
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
@@ -89,27 +212,240 @@ func _draw() -> void:
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
# ---------------------------------------------------------------------------
# Panel 2: World overlays
# ---------------------------------------------------------------------------
func _draw_world_overlays() -> void:
var vp := get_viewport()
if vp == null:
return
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
# Using this transform converts world coords → screen pixel coords for the overlays.
var canvas_xf := vp.get_canvas_transform()
var player_screen := _w2s(GameState.player_position, canvas_xf)
_draw_vision_cone(player_screen, canvas_xf)
_draw_los_rays(player_screen, canvas_xf)
_draw_npc_paths(canvas_xf)
_draw_info_tags(canvas_xf)
# Convert world tile position → screen pixel position
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
return canvas_xf * (world_pos * Constants.TILE_SIZE)
# Vision cone: filled forward sector + peripheral bands.
# Uses player facing direction and visibility sector distance.
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
var facing_angle := _facing_to_angle(GameState.player_facing)
# Estimate visible radius from furthest visibility sector tile
var max_d: float = 4.0
for vpos in GameState.visibility_sectors.keys():
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
if d > max_d:
max_d = d
var scale_x := canvas_xf.x.length()
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
var forward_from := facing_angle - CONE_FORWARD_HALF
var forward_to := facing_angle + CONE_FORWARD_HALF
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
var perip_l_to := facing_angle - CONE_FORWARD_HALF
var perip_r_from := facing_angle + CONE_FORWARD_HALF
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
# Forward arc boundary ring
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
# Player dot
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
# Build a filled polygon fan from center through an arc
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
var pts := PackedVector2Array()
pts.append(center)
for i in range(CONE_ARC_STEPS + 1):
var t := float(i) / float(CONE_ARC_STEPS)
var a := angle_from + t * (angle_to - angle_from)
pts.append(center + Vector2(cos(a), sin(a)) * radius)
return pts
# Dashed LOS lines from player to each visible non-player entity
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") == "Player":
continue
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
var entity_screen := _w2s(entity_world, canvas_xf)
var rel: String = entity.get("relationship", "Unknown")
var color := Constants.color_for_relationship(rel)
color.a = 0.45
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
# Fading NPC movement path trails from position history
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
for eid in _npc_paths.keys():
var path: Array = _npc_paths[eid]
if path.size() < 2:
continue
for i in range(1, path.size()):
var a_screen := _w2s(path[i - 1], canvas_xf)
var b_screen := _w2s(path[i], canvas_xf)
var alpha := float(i) / float(path.size())
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
# Information state tags above visible NPCs from player_knowledge
func _draw_info_tags(canvas_xf: Transform2D) -> void:
if GameState.player_knowledge == null:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var knowledge: Dictionary = GameState.player_knowledge
# Build entity_id → knowledge entry lookup
var kg_by_id: Dictionary = {}
for entry in knowledge.get("entities", []):
if entry is Dictionary and entry.has("entity_id"):
kg_by_id[entry.entity_id] = entry
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if not kg_by_id.has(eid):
continue
var kg_entry: Dictionary = kg_by_id[eid]
var confidence: String = kg_entry.get("confidence", "Unknown")
var name_str: String = kg_entry.get("name", "?")
var label := "%s [%s]" % [name_str, confidence]
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
var tag_baseline := entity_screen.y - 18.0
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
var tag_rect := Rect2(
entity_screen.x - text_w / 2.0 - 3.0,
tag_baseline - FONT_SIZE + 2.0,
text_w + 6.0,
FONT_SIZE)
draw_rect(tag_rect, TAG_BG_COLOR)
draw_string(font,
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
# ---------------------------------------------------------------------------
# Panel 3: Tick timing sparkline (bottom-left)
# ---------------------------------------------------------------------------
func _draw_tick_graph() -> void:
if _tick_deltas.size() < 2:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var vp_size := get_viewport_rect().size
var box_x := GRAPH_MARGIN
var label_h := LINE_HEIGHT
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
draw_string(font,
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
"tick ms (n=%d)" % _tick_deltas.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
var chart_top := box_y + label_h
var chart_left := box_x + 4.0
var chart_w := GRAPH_W - 8.0
var chart_h := GRAPH_H - 4.0
# Max value for scale
var max_ms: float = TICK_WARN_MS
for d in _tick_deltas:
if float(d) > max_ms:
max_ms = float(d)
max_ms *= 1.1
# Warn threshold dashed line
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
draw_dashed_line(
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
1.0, 4.0)
# Sparkline
var n := _tick_deltas.size()
var prev_pt := Vector2.ZERO
for i in range(n):
var x := chart_left + chart_w * (float(i) / float(n - 1))
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
var pt := Vector2(x, y)
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
if i > 0:
draw_line(prev_pt, pt, color, 1.5)
draw_circle(pt, 2.0, color)
prev_pt = pt
# Average label
var avg_ms := 0.0
for d in _tick_deltas:
avg_ms += float(d)
avg_ms /= float(_tick_deltas.size())
draw_string(font,
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
"avg %.0fms" % avg_ms,
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
static func _facing_to_angle(facing: String) -> float:
match facing:
"North": return -PI / 2.0
"Northeast": return -PI / 4.0
"East": return 0.0
"Southeast": return PI / 4.0
"South": return PI / 2.0
"Southwest": return PI * 3.0 / 4.0
"West": return PI
"Northwest": return -PI * 3.0 / 4.0
_: return -PI / 2.0
@@ -0,0 +1 @@
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env -S godot -s
## gdUnit4 CI runner for the Settled Reach client.
##
## Run all client tests headlessly:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/
##
## Run a specific test file:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
##
## Exit code: 0 = all pass, non-zero = failures.
##
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
## Test output format: JSON summary per D-030 sub-decision #6.
extends SceneTree
var _cli_runner: GdUnitTestCIRunner
func _initialize() -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
_cli_runner = GdUnitTestCIRunner.new()
root.add_child(_cli_runner)
func _finalize() -> void:
queue_delete(_cli_runner)
+29 -27
View File
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
# Immediately after update, self_modulate should NOT yet be the target
var color_after_immediate: Color = node.self_modulate
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
# The self_modulate should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
var color_first: Color = node.self_modulate
renderer.update_entities(entities)
var color_second: Color = node.color
var color_second: Color = node.self_modulate
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
if not f_node or not h_node:
return false
var f_color: Color = f_node.self_modulate
var h_color: Color = h_node.self_modulate
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
+317
View File
@@ -0,0 +1,317 @@
## Sprint 19 — Debug visualization overlay (#348)
## F3 toggle, world overlays, tick timing graph.
## Extends the existing debug_overlay.gd stub.
class_name TestDebugOverlaySprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
func _make_overlay() -> Control:
## Instantiate a standalone DebugOverlay control for unit testing.
## Does not require the full main.tscn scene tree.
var script := load(DEBUG_SCRIPT_PATH)
if script == null:
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
return null
var node := Control.new()
node.set_script(script)
add_child(node)
return node
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.current_tick = 1
GameState.player_knowledge = null
func after_test() -> void:
GameState.visible_entities = []
GameState.player_knowledge = null
# ---------------------------------------------------------------------------
# Script existence
# ---------------------------------------------------------------------------
func test_debug_overlay_script_exists() -> void:
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
).is_true()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
func test_debug_overlay_instantiates_without_crash() -> void:
var ol := _make_overlay()
if ol == null: return
assert_that(ol).is_not_null()
ol.queue_free()
func test_debug_overlay_starts_hidden() -> void:
## Overlay starts hidden — only appears when F3 pressed.
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.visible).override_failure_message(
"DebugOverlay must start hidden (visible=false)"
).is_false()
ol.queue_free()
# ---------------------------------------------------------------------------
# Dev-only guard
# ---------------------------------------------------------------------------
func test_update_from_state_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has_method("update_from_state")).override_failure_message(
"DebugOverlay must have update_from_state() method"
).is_true()
ol.queue_free()
func test_update_from_state_does_not_crash_when_hidden() -> void:
## update_from_state() called while hidden must not crash.
var ol := _make_overlay()
if ol == null: return
ol.visible = false
ol.update_from_state() # Should be a no-op, no crash
ol.queue_free()
func test_update_from_state_does_not_crash_when_visible() -> void:
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate a minimal snapshot tick
GameState.current_tick = 42
ol.update_from_state()
ol.queue_free()
# ---------------------------------------------------------------------------
# NPC path tracking
# ---------------------------------------------------------------------------
func test_npc_paths_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_npc_paths")).override_failure_message(
"DebugOverlay must have _npc_paths field for NPC movement history"
).is_true()
ol.queue_free()
func test_npc_paths_updated_on_state_update() -> void:
## After update_from_state with an NPC entity, _npc_paths should have an entry.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 2,
"x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 100
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"_npc_paths must record NPC positions from visible_entities"
).is_greater(0)
ol.queue_free()
func test_npc_paths_not_populated_for_player_entity() -> void:
## Player entities must not appear in NPC path history.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 1,
"x": 10.0, "y": 10.0, "z": 0,
"kind": {"variant": "Player", "data": null},
}]
GameState.current_tick = 101
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"Player entity must not appear in _npc_paths"
).is_equal(0)
ol.queue_free()
func test_npc_paths_max_length_respected() -> void:
## Path history must not grow beyond NPC_HISTORY_LEN entries.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate NPC moving each tick — inject 20 ticks of movement
for i in range(20):
GameState.visible_entities = [{
"entity_id": 5,
"x": float(12 + i), "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 200 + i
ol.update_from_state()
var path: Array = ol._npc_paths.get(5, [])
assert_int(path.size()).override_failure_message(
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
).is_less_equal(ol.NPC_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Tick timing ring
# ---------------------------------------------------------------------------
func test_tick_deltas_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_tick_deltas")).override_failure_message(
"DebugOverlay must have _tick_deltas field for timing sparkline"
).is_true()
ol.queue_free()
func test_tick_deltas_accumulate_over_state_updates() -> void:
## Each new tick snapshot should add a delta to _tick_deltas.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(5):
GameState.current_tick = 300 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must accumulate entries from successive ticks"
).is_greater(0)
ol.queue_free()
func test_tick_deltas_max_length_respected() -> void:
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(50):
GameState.current_tick = 400 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
).is_less_equal(ol.TICK_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Constants defined
# ---------------------------------------------------------------------------
func test_npc_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have NPC_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have TICK_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_warn_ms_constant_defined() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
).is_true()
ol.queue_free()
# ---------------------------------------------------------------------------
# Facing angle helper
# ---------------------------------------------------------------------------
func test_facing_to_angle_north() -> void:
## North = -PI/2 in Godot 2D (up on screen)
var angle := _fetch_facing_angle("North")
assert_float(angle).override_failure_message(
"_facing_to_angle('North') must return -PI/2"
).is_equal_approx(-PI / 2.0, 0.001)
func test_facing_to_angle_east() -> void:
var angle := _fetch_facing_angle("East")
assert_float(angle).is_equal_approx(0.0, 0.001)
func test_facing_to_angle_south() -> void:
var angle := _fetch_facing_angle("South")
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
func test_facing_to_angle_west() -> void:
var angle := _fetch_facing_angle("West")
assert_float(angle).is_equal_approx(PI, 0.001)
func _fetch_facing_angle(facing: String) -> float:
## Helper: load script and call static method.
var script = load(DEBUG_SCRIPT_PATH)
if script == null:
return 0.0
# In GDScript 4, static methods can be called via an instance
var tmp := Control.new()
tmp.set_script(script)
add_child(tmp)
var result := tmp._facing_to_angle(facing)
tmp.queue_free()
return result
# ---------------------------------------------------------------------------
# In-scene placement: DebugOverlay on UILayer
# ---------------------------------------------------------------------------
func test_debug_overlay_in_main_scene_ui_layer() -> void:
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var ui_layer := scene.get_node_or_null("UILayer")
assert_that(ui_layer != null).override_failure_message(
"UILayer must exist in main.tscn"
).is_true()
if ui_layer == null: return
var overlay := ui_layer.get_node_or_null("DebugOverlay")
assert_that(overlay != null).override_failure_message(
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
).is_true()
+598
View File
@@ -0,0 +1,598 @@
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
## D-064 (walk-away), D-078 (overheard log)
##
## Test plan from joint.md:
## "Manual: examine result appears as overlay, auto-dismisses.
## Dialogue options confirmed: no locked/grayed options visible.
## Confrontation option in italic voice."
##
## Unit-testable coverage here:
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
## - D-063: confrontation beat duration, signal, dim alpha
## - D-064: walk-away fires dialogue_dismissed signal
## - GameState: current_dialogue parsing
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
## - BBCode: escape contract (Hoshe #2 regression guard)
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
class_name TestDialogueSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
var opts: Array = []
for i in range(texts.size()):
var opt: Dictionary = {
"text": texts[i],
"response_id": "r%d" % i,
"priority": i,
}
if confrontation_flags.size() > i:
opt["confrontation"] = confrontation_flags[i]
opts.append(opt)
return opts
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
func after_test() -> void:
GameState.current_dialogue = null
GameState.dialogue_active = false
if GameState.has("current_examine_result"):
GameState.current_examine_result = null
# ---------------------------------------------------------------------------
# D-062: No locked/grayed options
## Per D-062: locked options are INVISIBLE — not shown at all.
## The client renders all received options as active, clickable labels.
## Server responsibility: omit locked options from the array.
## Test verifies: no disabled/locked styling is applied to any rendered option.
# ---------------------------------------------------------------------------
func test_d062_rendered_options_have_no_disabled_state() -> void:
## D-062: All options from server render as active controls.
## No option should have mouse_filter=IGNORE (which would indicate disabled).
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
var labels := options_container.get_children()
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
for label in labels:
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
assert_int(label.mouse_filter).override_failure_message(
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
box.queue_free()
func test_d062_no_lock_icon_children_on_options() -> void:
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
## Any child node on an option label would indicate a locked-option indicator.
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
var labels := options_container.get_children()
assert_int(labels.size()).is_greater(0)
for label in labels:
assert_int(label.get_child_count()).override_failure_message(
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
).is_equal(0)
box.queue_free()
func test_d062_max_options_constant_is_three() -> void:
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
## are shown — server must omit locked ones, client only truncates to 3.
## Verify MAX_OPTIONS constant is locked at 3.
var box := _make_dialogue_box()
if box == null: return
assert_int(box.MAX_OPTIONS).override_failure_message(
"MAX_OPTIONS must be 3 per D-061 spec"
).is_equal(3)
box.queue_free()
func test_d062_server_sends_four_options_only_three_render() -> void:
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
## only top 3 by priority render. No 4th option appears.
var box := _make_dialogue_box()
if box == null: return
var opts := _make_options(["A", "B", "C", "D"])
# Assign explicit priorities so sort is deterministic
for i in range(opts.size()):
opts[i]["priority"] = i
box.show_dialogue("NPC", "Speech.", opts)
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
if options_container == null: box.queue_free(); return
assert_int(options_container.get_child_count()).override_failure_message(
"Only 3 options must render even when server sends 4 (D-061 truncation)"
).is_equal(3)
box.queue_free()
# ---------------------------------------------------------------------------
# D-063: Confrontation beat
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
## Panel dims during beat. confrontation_monologue signal fires.
# ---------------------------------------------------------------------------
func test_d063_beat_duration_within_spec() -> void:
## D-063: The beat duration must be 1–2 seconds per spec.
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
"D-063: confrontation beat must be 1.0–2.0 seconds"
).is_between(1.0, 2.0)
box.queue_free()
func test_d063_dim_alpha_is_set() -> void:
## D-063: The dialogue box dims during the confrontation beat.
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
var box := _make_dialogue_box()
if box == null: return
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
).is_less(1.0)
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
).is_greater(0.0)
box.queue_free()
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
var received_text := ""
box.confrontation_monologue.connect(func(text: String, _dur: float):
signal_fired = true
received_text = text
)
# Show dialogue with one confrontation option
var opts := _make_options(["I know what you did."], [true])
box.show_dialogue("NPC", "Everything is fine.", opts)
# Press option 1 (index 0)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
).is_true()
box.queue_free()
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
box.confrontation_monologue.connect(func(_text: String, _dur: float):
signal_fired = true
)
var opts := _make_options(["A normal response."], [false])
box.show_dialogue("NPC", "Hello.", opts)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
"D-063: confrontation_monologue must NOT fire for standard options"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-064: Walk-away mechanic
## WASD during active conversation fires dialogue_dismissed signal.
# ---------------------------------------------------------------------------
func test_d064_walk_away_actions_constant_not_empty() -> void:
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
## Prevents accidental empty-array regression.
var box := _make_dialogue_box()
if box == null: return
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
).is_greater_equal(4)
box.queue_free()
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
var box := _make_dialogue_box()
if box == null: return
var actions: Array = box._WALK_AWAY_ACTIONS
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
assert_bool(required in actions).override_failure_message(
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
).is_true()
box.queue_free()
func test_d064_dialogue_dismissed_signal_connection() -> void:
## D-064: dialogue_dismissed signal must exist on DialogueBox.
## The signal drives walk-away behavior in main.gd.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
"D-064: dialogue_dismissed signal must exist on DialogueBox"
).is_true()
box.queue_free()
# ---------------------------------------------------------------------------
# GameState: current_dialogue snapshot parsing
## D-061: current_dialogue is set from snapshot, null when absent.
# ---------------------------------------------------------------------------
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
## apply_snapshot with current_dialogue dict populates the field.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael Davan",
"npc_entity_id": 42,
"speech": "You don't belong here.",
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
},
})
assert_that(GameState.current_dialogue).is_not_null()
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
func test_gamestate_current_dialogue_null_when_absent() -> void:
## apply_snapshot without current_dialogue clears the field.
## Prevents stale dialogue from persisting across ticks.
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
## Non-dict current_dialogue is rejected — defensive against malformed server data.
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
assert_that(GameState.current_dialogue).is_null()
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
## The options array must survive snapshot parsing for DialogueBox to render them.
var options := [
{"text": "A", "response_id": "r1", "priority": 1},
{"text": "B", "response_id": "r2", "priority": 2},
]
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "NPC",
"npc_entity_id": 1,
"speech": "Choose.",
"options": options,
},
})
assert_that(GameState.current_dialogue).is_not_null()
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
assert_int(parsed_opts.size()).is_equal(2)
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
# ---------------------------------------------------------------------------
# GameState: current_examine_result snapshot parsing (test-first, #174)
## Sprint 18: examine verb returns character-filtered observation text.
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
## GameState must expose current_examine_result for the overlay display node.
# ---------------------------------------------------------------------------
func test_gamestate_examine_result_field_exists() -> void:
## GameState must have a current_examine_result field (Sprint 18, #174).
## Fails until Stig adds the field to game_state.gd.
assert_bool(GameState.has("current_examine_result")).override_failure_message(
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
).is_true()
func test_gamestate_examine_result_null_by_default() -> void:
## current_examine_result defaults to null (no examine active).
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
return
GameState.current_examine_result = null
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_set_from_snapshot() -> void:
## apply_snapshot with examine_result dict populates current_examine_result.
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 5,
"examine_result": {
"entity_id": 12,
"text": "Kael Davan — nervous energy. He's scanning exits.",
"confidence": "KnowsOf",
},
})
assert_that(GameState.current_examine_result).is_not_null()
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
func test_gamestate_examine_result_null_when_absent() -> void:
## apply_snapshot without examine_result must clear the field.
## Prevents stale examine overlay persisting beyond auto-dismiss window.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
return
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
GameState.apply_snapshot({"tick": 6})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_null_when_non_dict() -> void:
## Malformed examine_result (not a dict) must be rejected.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
return
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
## entity_id is needed to anchor the overlay above the correct entity.
if not GameState.has("current_examine_result"):
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 1,
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
})
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
# ---------------------------------------------------------------------------
# BBCode injection guard (regression: Hoshe #2)
## Server-sourced text containing BBCode brackets must be escaped.
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
# ---------------------------------------------------------------------------
func test_escape_bbcode_brackets_in_server_text() -> void:
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
## as plain text in the dialogue log.
var box := _make_dialogue_box()
if box == null: return
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
assert_that(escaped).contains("[lb]")
assert_bool(escaped.begins_with("[")).is_false()
box.queue_free()
func test_escape_bbcode_plain_text_unchanged() -> void:
## Non-BBCode text must not be modified by _escape_bbcode.
var box := _make_dialogue_box()
if box == null: return
var plain := "Kael Davan"
assert_that(box._escape_bbcode(plain)).is_equal(plain)
box.queue_free()
func test_escape_bbcode_multiple_brackets() -> void:
## Multiple '[' characters all get escaped.
var box := _make_dialogue_box()
if box == null: return
var text := "[b]Bold[/b] and [i]italic[/i]"
var escaped: String = box._escape_bbcode(text)
assert_bool(escaped.contains("[b]")).is_false()
assert_bool(escaped.contains("[i]")).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# Log dirty flag optimization (regression: Hoshe #1)
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
# ---------------------------------------------------------------------------
func test_log_dirty_false_on_init() -> void:
## _log_dirty starts false — no rebuild needed before first line.
var box := _make_dialogue_box()
if box == null: return
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false on init — no unnecessary rebuild"
).is_false()
box.queue_free()
func test_log_dirty_set_after_append_line() -> void:
## Appending a line sets _log_dirty = true.
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hello.", false)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be true after append_line to trigger rebuild next _process"
).is_true()
box.queue_free()
func test_log_dirty_cleared_after_process() -> void:
## After _process(), _log_dirty is cleared (rebuild done).
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "One line.", false)
assert_bool(box._log_dirty).is_true()
box._process(0.0)
assert_bool(box._log_dirty).override_failure_message(
"_log_dirty must be false after _process (rebuild consumed the flag)"
).is_false()
box.queue_free()
# ---------------------------------------------------------------------------
# D-061: Dialogue box size constraints
# ---------------------------------------------------------------------------
func test_d061_max_height_ratio_is_twenty_percent() -> void:
## D-061: dialogue box must occupy max 20% viewport height.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d061_fade_in_is_200ms() -> void:
## D-061: fade-in on dialogue appearance is 0.2s.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_IN).override_failure_message(
"D-061: FADE_IN must be 0.2s"
).is_equal_approx(0.2, 0.001)
box.queue_free()
func test_d064_fade_out_is_300ms() -> void:
## D-064: walk-away fade-out is 300ms per spec.
var box := _make_dialogue_box()
if box == null: return
assert_float(box.FADE_OUT).override_failure_message(
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
).is_equal_approx(0.3, 0.001)
box.queue_free()
# ---------------------------------------------------------------------------
# Passive log entry (D-078: overheard NPC-NPC)
# ---------------------------------------------------------------------------
func test_passive_entry_uses_bar_glyph() -> void:
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
## Verifies the glyph constant is the correct Unicode bar character.
var box := _make_dialogue_box()
if box == null: return
assert_that(box.PASSIVE_GLYPH).override_failure_message(
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
).is_equal("\u2503 ")
box.queue_free()
func test_passive_entry_appended_and_marked_is_passive() -> void:
## D-078: append_conversation_event creates a passive log entry.
var box := _make_dialogue_box()
if box == null: return
var event := {
"speaker_id": 10, "target_id": 11,
"speaker_name": "Guard A", "target_name": "Guard B",
"occluded_line": "Did you see the detective?",
"speaker_color_index": 0, "target_color_index": 1,
}
box.append_conversation_event(event)
assert_int(box._log_entries.size()).is_equal(1)
assert_bool(box._log_entries[0].is_passive).override_failure_message(
"D-078: overheard entry must be marked is_passive = true"
).is_true()
box.queue_free()
func test_passive_entry_empty_text_not_appended() -> void:
## D-078: Conversation event with empty occluded_line is silently dropped.
var box := _make_dialogue_box()
if box == null: return
box.append_conversation_event({
"speaker_id": 1, "target_id": 2,
"speaker_name": "A", "target_name": "B",
"occluded_line": "",
})
assert_int(box._log_entries.size()).override_failure_message(
"D-078: empty occluded_line must not produce a log entry"
).is_equal(0)
box.queue_free()
# ---------------------------------------------------------------------------
# has_active_entries / is_dialogue_active
# ---------------------------------------------------------------------------
func test_has_active_entries_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.has_active_entries()).is_false()
box.queue_free()
func test_has_active_entries_true_after_append() -> void:
var box := _make_dialogue_box()
if box == null: return
box.append_line("NPC", "Player", "Hey.", false)
assert_bool(box.has_active_entries()).is_true()
box.queue_free()
func test_is_dialogue_active_false_on_init() -> void:
var box := _make_dialogue_box()
if box == null: return
assert_bool(box.is_dialogue_active()).is_false()
box.queue_free()
func test_is_dialogue_active_true_after_show_dialogue() -> void:
var box := _make_dialogue_box()
if box == null: return
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(box.is_dialogue_active()).is_true()
box.queue_free()
+298
View File
@@ -0,0 +1,298 @@
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
## Tests for relationship color indicator in interaction_list.gd.
##
## Spec refs:
## D-033 (entity color = relationship to player)
## D-051 (diegetic insert display — insert-only overlay)
## D-057 (interaction list, z-layer 6)
##
## Phase 1 scope (2026-02-24):
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
## server protocol change (no known_attributes in wire protocol v13).
##
## Implementation: client/ui/interaction_list.gd
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
## `func _cache_entity_relationship() -> void`
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
class_name TestETalkOverlaySprint17
extends GdUnitTestSuite
func before_test() -> void:
SimBridge.reset_test_state()
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
func after_test() -> void:
GameState.nearby_interactions = []
GameState.visible_entities = []
GameState.player_stance = ""
# -------------------------------------------------------------------------
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
# -------------------------------------------------------------------------
func test_relationship_unknown_maps_to_teal() -> void:
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_friendly_maps_to_green() -> void:
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_poi_maps_to_amber() -> void:
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_hostile_maps_to_red() -> void:
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_known_falls_back_to_unknown_color() -> void:
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# -------------------------------------------------------------------------
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
# -------------------------------------------------------------------------
func test_ui_strings_has_relationship_unknown_label() -> void:
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
func test_ui_strings_has_relationship_known_label() -> void:
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
func test_ui_strings_has_relationship_friendly_label() -> void:
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
func test_ui_strings_has_relationship_poi_label() -> void:
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
func test_ui_strings_has_relationship_hostile_label() -> void:
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
# -------------------------------------------------------------------------
# _relationship_color initial state
# -------------------------------------------------------------------------
func test_relationship_color_default_is_implant_dim() -> void:
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
var list = _make_list()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
# -------------------------------------------------------------------------
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
# -------------------------------------------------------------------------
func test_cache_relationship_unknown_sets_unknown_color() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_friendly_sets_green() -> void:
_setup_npc_interaction(2, "Friendly")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_cache_relationship_poi_sets_amber() -> void:
_setup_npc_interaction(2, "PersonOfInterest")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
list.queue_free()
func test_cache_relationship_hostile_sets_red() -> void:
_setup_npc_interaction(2, "Hostile")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
list.queue_free()
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
GameState.visible_entities = []
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
list.queue_free()
func test_cache_relationship_updates_when_relationship_changes() -> void:
# First call: Unknown
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# Relationship shifts → re-cache picks up new value
_setup_npc_interaction(2, "Hostile")
list.update_from_state()
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
list.queue_free()
func test_cache_relationship_targets_correct_entity_by_id() -> void:
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
# Entity 3 (Hostile) must not pollute the color
GameState.visible_entities = [
{
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
},
{
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
},
]
GameState.nearby_interactions = [
{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
{
"entity_id": 3, "entity_type": "Npc", "distance": 2,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
]
var list = _make_list()
list.update_from_state()
# interaction[0] = entity 2 (Friendly) → green bar
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
list.queue_free()
func test_z_layer_is_insert_canvas() -> void:
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
var list = _make_list()
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -------------------------------------------------------------------------
# Regression: interaction_list public API unaffected by #537 changes
# -------------------------------------------------------------------------
func test_list_hides_when_no_interactions() -> void:
GameState.nearby_interactions = []
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_shows_on_npc_interaction() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_true()
list.queue_free()
func test_list_get_selected_verb_returns_first_verb() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
],
}]
var list = _make_list()
assert_that(list.get_selected_verb()).is_equal("Talk")
list.queue_free()
func test_list_get_interaction_target_returns_entity_id() -> void:
GameState.visible_entities = [{
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 5, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.get_interaction_target()).is_equal(5)
list.queue_free()
func test_list_suppressed_when_insert_inactive() -> void:
_setup_npc_interaction(2, "Unknown")
var list = _make_list()
list.set_insert_active(false)
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
func test_list_hides_on_empty_verb_list() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
}]
var list = _make_list()
list.update_from_state()
assert_that(list.is_showing()).is_false()
list.queue_free()
# -------------------------------------------------------------------------
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
# -------------------------------------------------------------------------
func skip_test_npc_name_displayed_when_known() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
pass
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
func _make_list() -> Control:
var scene = load("res://ui/interaction_list.tscn")
var list = scene.instantiate()
add_child(list) # _ready() fires here — @onready var _vbox resolves
return list
## Set up GameState with a single NPC entity + matching interaction for tests.
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
GameState.visible_entities = [{
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
+156
View File
@@ -0,0 +1,156 @@
## GameState.apply_snapshot() tests — v2+ field coverage.
##
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
## player_position). This file covers v2+ fields and derived state.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameState
extends GdUnitTestSuite
func before_each() -> void:
# Reset fields touched by these tests to known defaults.
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.player_facing = "North"
GameState.game_time = {}
GameState.nearby_interactions = []
GameState.current_monologue = null
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState.insert_active = true
# -- v2: game_time (D-031) -------------------------------------------------
func test_apply_snapshot_sets_game_time() -> void:
var snapshot := {
"tick": 10,
"entities": [],
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.game_time).is_not_null()
assert_that(GameState.game_time.get("day")).is_equal(3)
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
GameState.game_time = {"day": 2, "time_of_day": 360}
GameState.apply_snapshot({"tick": 5, "entities": []})
# No "game_time" key → field unchanged
assert_that(GameState.game_time.get("day")).is_equal(2)
# -- v2: player_facing (D-015) --------------------------------------------
func test_apply_snapshot_sets_player_facing() -> void:
var snapshot := {
"tick": 1,
"entities": [],
"player_facing": "Southeast",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.player_facing).is_equal("Southeast")
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
GameState.player_facing = "West"
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.player_facing).is_equal("West")
# -- v4: nearby_interactions (#404/#405) ----------------------------------
func test_apply_snapshot_sets_nearby_interactions() -> void:
var interactions := [
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
GameState.nearby_interactions = [{"entity_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.nearby_interactions.size()).is_equal(0)
# -- v5: current_monologue (#414) -----------------------------------------
func test_apply_snapshot_sets_monologue() -> void:
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
func test_apply_snapshot_monologue_absent_clears_field() -> void:
GameState.current_monologue = {"id": "old", "text": "Old line."}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.current_monologue).is_null()
# -- v6: player_stance (#449, D-053) --------------------------------------
func test_apply_snapshot_sets_player_stance() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
assert_that(GameState.player_stance).is_equal("Crouch")
# -- v6: player_inventory (#449, D-065) -----------------------------------
func test_apply_snapshot_sets_player_inventory() -> void:
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
assert_that(GameState.player_inventory.size()).is_equal(1)
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
func test_apply_snapshot_inventory_absent_clears_list() -> void:
GameState.player_inventory = [{"item_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.player_inventory.size()).is_equal(0)
# -- Stationary tick counter (D-071) -------------------------------------
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_resets_on_player_movement() -> void:
var s1 := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
var s2 := {
"tick": 2,
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(s1)
GameState.apply_snapshot(s1) # stationary
assert_int(GameState.stationary_ticks).is_greater(0)
GameState.apply_snapshot(s2) # moved → reset
assert_int(GameState.stationary_ticks).is_equal(0)
# -- insert_active (OQ-07, #522) -----------------------------------------
func test_apply_snapshot_insert_active_false() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
assert_bool(GameState.insert_active).is_false()
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
GameState.insert_active = false
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_bool(GameState.insert_active).is_true()
+601
View File
@@ -0,0 +1,601 @@
## Sprint 18 — Knowledge/journal display (#264)
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
## D-042 (UIStrings for all labels)
##
## Tests now run against live Stig implementation.
## Wire format per game_state.gd v14:
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
##
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
## The server sends KG updates only when the graph changes — absence = no change.
## Contrast with current_examine_result which DOES clear each snapshot.
class_name TestJournalSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
func _make_journal_panel() -> Control:
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
return null
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
var base: Dictionary = {
"entity_id": 42,
"name": "Kael Davan",
"confidence": "KnowsOf",
"source": "DirectObservation",
"state": "Active",
"relationship": "PersonOfInterest",
"last_observed_tick": 1024,
}
base.merge(overrides, true)
return base
func _make_player_knowledge(entities: Array = []) -> Dictionary:
if entities.is_empty():
entities = [_make_kg_entity()]
return {"entities": entities}
func _entries_container(panel: Control) -> Node:
return panel.get_node_or_null(
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.current_tick = 0
func after_test() -> void:
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
# ---------------------------------------------------------------------------
# GameState: player_knowledge snapshot parsing
# ---------------------------------------------------------------------------
func test_gamestate_player_knowledge_field_exists() -> void:
## GameState must have player_knowledge field (v14, #264).
assert_bool(GameState.has("player_knowledge")).override_failure_message(
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
).is_true()
func test_gamestate_player_knowledge_null_by_default() -> void:
GameState.player_knowledge = null
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 10,
"player_knowledge": _make_player_knowledge(),
})
assert_that(GameState.player_knowledge).is_not_null()
assert_bool(GameState.player_knowledge.has("entities")).is_true()
func test_gamestate_player_knowledge_persists_when_absent() -> void:
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
## Server sends KG updates only on change — absence means "no change since last tick".
## This is intentional behavior (journal should not flash empty every tick).
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 11})
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
## Malformed player_knowledge (non-dict) must be rejected.
## First set a valid value, then try to overwrite with invalid
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
# Non-dict is rejected — previous value preserved (or null if first time)
# The implementation only updates on Dictionary type, so value persists
assert_that(GameState.player_knowledge).is_not_null()
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
var entities := [
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
]
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(parsed_entities.size()).is_equal(2)
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
## When server sends a new player_knowledge, it replaces the previous value.
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
])})
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
_make_kg_entity({"name": "Person A"}),
_make_kg_entity({"name": "Person B", "entity_id": 99}),
])})
var entities: Array = GameState.player_knowledge.get("entities", [])
assert_int(entities.size()).is_equal(2)
# ---------------------------------------------------------------------------
# Journal panel: scene and API
# ---------------------------------------------------------------------------
func test_journal_panel_scene_exists() -> void:
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
"Journal panel scene must exist at res://ui/journal_panel.tscn"
).is_true()
func test_journal_panel_instantiates_without_crash() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_that(panel).is_not_null()
panel.queue_free()
func test_journal_panel_has_toggle_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("toggle")).override_failure_message(
"JournalPanel must have toggle() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_close_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("close")).override_failure_message(
"JournalPanel must have close() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_is_open_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("is_open")).override_failure_message(
"JournalPanel must have is_open() method"
).is_true()
panel.queue_free()
func test_journal_panel_has_update_from_state_method() -> void:
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.has_method("update_from_state")).override_failure_message(
"JournalPanel must have update_from_state() method (called from main.gd)"
).is_true()
panel.queue_free()
func test_journal_panel_closed_on_init() -> void:
## Panel starts hidden — not open by default.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.is_open()).override_failure_message(
"JournalPanel must be closed on _ready()"
).is_false()
panel.queue_free()
func test_journal_panel_toggle_opens() -> void:
## First toggle() opens the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle()
assert_bool(panel.is_open()).override_failure_message(
"toggle() must set is_open() = true"
).is_true()
panel.queue_free()
func test_journal_panel_toggle_closes() -> void:
## Second toggle() closes the panel.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open
panel.toggle() # close
assert_bool(panel.is_open()).override_failure_message(
"Second toggle() must close the panel"
).is_false()
panel.queue_free()
func test_journal_panel_close_when_already_closed_is_safe() -> void:
## close() on an already-closed panel must not crash.
var panel := _make_journal_panel()
if panel == null: return
panel.close()
assert_bool(panel.is_open()).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: entry rendering
# ---------------------------------------------------------------------------
func test_journal_panel_entries_container_exists() -> void:
## EntriesContainer is the VBoxContainer that holds entity entries.
var panel := _make_journal_panel()
if panel == null: return
var container := _entries_container(panel)
assert_that(container != null).override_failure_message(
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
).is_true()
panel.queue_free()
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Kael Davan"}),
])
panel.toggle() # calls _show_panel() -> _rebuild_entries()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer must have children when player_knowledge is populated"
).is_greater(0)
panel.queue_free()
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
## Empty state Label is shown when player_knowledge is null.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = null
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Empty state = exactly 1 child (the "Nothing logged yet." label)
assert_int(container.get_child_count()).override_failure_message(
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
).is_equal(1)
panel.queue_free()
func test_journal_panel_two_entities_create_more_entries() -> void:
## Two entities create more entries than one (header + detail each, plus spacers).
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
assert_int(container.get_child_count()).override_failure_message(
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
).is_greater_equal(6)
panel.queue_free()
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
## D-041: Contradicted entities must have strikethrough in their header BBCode.
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
## First child should be the header RichTextLabel with [s]...[/s]
if container.get_child_count() == 0:
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_that(first_child.text).override_failure_message(
"Contradicted entity header must contain [s] (strikethrough) BBCode"
).contains("[s]")
panel.queue_free()
func test_journal_panel_active_entity_no_strikethrough() -> void:
## Active entity must NOT have strikethrough in its header.
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
])
panel.toggle()
var container := _entries_container(panel)
if container == null: panel.queue_free(); return
if container.get_child_count() == 0:
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
panel.queue_free(); return
var first_child := container.get_child(0)
if first_child is RichTextLabel:
assert_bool(first_child.text.contains("[s]")).override_failure_message(
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
).is_false()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: mutual exclusion with dialogue
# ---------------------------------------------------------------------------
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
## Sprint briefing: journal must close when dialogue opens.
## update_from_state() is called from main.gd on each snapshot.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
assert_bool(panel.is_open()).is_true()
GameState.dialogue_active = true
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
).is_false()
panel.queue_free()
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
## update_from_state() must NOT close journal when dialogue is not active.
var panel := _make_journal_panel()
if panel == null: return
panel.toggle() # open journal
GameState.dialogue_active = false
panel.update_from_state()
assert_bool(panel.is_open()).override_failure_message(
"Journal must stay open when dialogue is inactive"
).is_true()
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
# ---------------------------------------------------------------------------
func test_ui_strings_confidence_direct_exists() -> void:
## D-042: confidence label for "Direct" tier must be in UIStrings.
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsdetails_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
).is_true()
func test_ui_strings_confidence_knowsof_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
).is_true()
func test_ui_strings_confidence_suspects_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
).is_true()
func test_ui_strings_all_confidence_keys_non_empty() -> void:
## All four confidence label values must be non-empty strings.
var keys := [
"knowledge_panel.confidence_direct",
"knowledge_panel.confidence_knowsdetails",
"knowledge_panel.confidence_knowsof",
"knowledge_panel.confidence_suspects",
]
for key in keys:
if not UIStrings.has_key(key): continue
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
"UIStrings key '%s' must be non-empty" % key
).is_true()
func test_ui_strings_source_directobservation_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
).is_true()
func test_ui_strings_source_toldby_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
).is_true()
func test_ui_strings_source_heard_exists() -> void:
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
).is_true()
# ---------------------------------------------------------------------------
# Journal panel: _state_color() contract
# ---------------------------------------------------------------------------
func test_state_color_contradicted_is_amber() -> void:
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Contradicted")
assert_that(color).override_failure_message(
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
).is_equal(Constants.ENTITY_COLOR_POI)
panel.queue_free()
func test_state_color_stale_is_dimmed() -> void:
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Stale")
assert_that(color).override_failure_message(
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
).is_equal(Constants.IMPLANT_TEXT_DIM)
panel.queue_free()
func test_state_color_active_is_normal() -> void:
## Active → normal insert text color (INSERT_COLOR_TEXT).
var panel := _make_journal_panel()
if panel == null: return
var color: Color = panel._state_color("Active")
assert_that(color).override_failure_message(
"_state_color('Active') must return INSERT_COLOR_TEXT"
).is_equal(Constants.INSERT_COLOR_TEXT)
panel.queue_free()
func test_state_color_contradicted_differs_from_active() -> void:
## Contradicted and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var contradicted := panel._state_color("Contradicted")
var active := panel._state_color("Active")
assert_that(contradicted).is_not_equal(active)
panel.queue_free()
func test_state_color_stale_differs_from_active() -> void:
## Stale and Active must have visually distinct colors.
var panel := _make_journal_panel()
if panel == null: return
var stale := panel._state_color("Stale")
var active := panel._state_color("Active")
assert_that(stale).is_not_equal(active)
panel.queue_free()
# ---------------------------------------------------------------------------
# UIStrings: knowledge_panel keys (D-042)
# ---------------------------------------------------------------------------
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
## Journal title uses knowledge_panel.tab_contacts.
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
## Empty state message uses knowledge_panel.empty_state.
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
).is_true()
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.empty_state"): return
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
# ---------------------------------------------------------------------------
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
## Regression guard: verify the dicts are gone and UIStrings fallback works.
# ---------------------------------------------------------------------------
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
## If this test fails, the hardcoded dict was accidentally re-introduced.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
var panel := _make_journal_panel()
if panel == null: return
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
).is_true()
panel.queue_free()
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
## UIStrings falls back to the key string itself for missing keys.
## journal_panel.gd relies on this for graceful degradation.
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
assert_that(fallback).override_failure_message(
"UIStrings fallback must return the key string itself for unknown keys"
).is_equal("knowledge_panel.confidence_nonexistent_level")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
func test_canvas_insert_constant_is_10() -> void:
assert_int(Constants.CANVAS_INSERT).is_equal(10)
func test_journal_fade_constants_reasonable() -> void:
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
var panel := _make_journal_panel()
if panel == null: return
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
panel.queue_free()
@@ -0,0 +1,189 @@
## Sprint 19 — Game session management (#258, D-085)
## Per-game save directories: created on New Game, resumed via game-id.
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
class_name TestSessionManagerSprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_game_id = ""
func after_test() -> void:
GameState.current_game_id = ""
# ---------------------------------------------------------------------------
# GameState.current_game_id field
# ---------------------------------------------------------------------------
func test_current_game_id_field_exists() -> void:
## D-085: GameState must have current_game_id field.
assert_bool(GameState.has("current_game_id")).override_failure_message(
"GameState must have 'current_game_id' field (D-085 #258)"
).is_true()
func test_current_game_id_default_is_empty_string() -> void:
## Before any session starts, current_game_id is empty.
GameState.current_game_id = ""
assert_str(GameState.current_game_id).override_failure_message(
"GameState.current_game_id default must be empty string"
).is_empty()
# ---------------------------------------------------------------------------
# SessionManager autoload exists
# ---------------------------------------------------------------------------
func test_session_manager_autoload_exists() -> void:
## SessionManager must be registered as an autoload.
var sm := Engine.get_singleton("SessionManager")
assert_that(sm != null).override_failure_message(
"SessionManager must be registered as autoload in project.godot (#258)"
).is_true()
# ---------------------------------------------------------------------------
# new_game() — game-id format and GameState update
# ---------------------------------------------------------------------------
func test_new_game_returns_non_empty_string() -> void:
var game_id := SessionManager.new_game()
assert_str(game_id).override_failure_message(
"SessionManager.new_game() must return a non-empty game-id string"
).is_not_empty()
func test_new_game_sets_current_game_id_on_gamestate() -> void:
var game_id := SessionManager.new_game()
assert_str(GameState.current_game_id).override_failure_message(
"new_game() must set GameState.current_game_id"
).is_equal(game_id)
func test_new_game_id_format_has_two_dashes() -> void:
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts.size()).override_failure_message(
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
).is_equal(3)
func test_new_game_id_first_part_is_8_digits() -> void:
## First part is YYYYMMDD — 8 decimal digits.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[0].length()).override_failure_message(
"game-id first part (date) must be 8 characters (YYYYMMDD)"
).is_equal(8)
func test_new_game_id_second_part_is_6_digits() -> void:
## Second part is HHMMSS — 6 decimal digits.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[1].length()).override_failure_message(
"game-id second part (time) must be 6 characters (HHMMSS)"
).is_equal(6)
func test_new_game_id_third_part_is_6_hex_chars() -> void:
## Third part is 6 hex characters (RNG seed).
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[2].length()).override_failure_message(
"game-id third part (hex seed) must be 6 characters"
).is_equal(6)
func test_new_game_ids_are_unique() -> void:
## Two rapid new_game() calls should produce different IDs
## (different RNG seeds; same-second timestamps are valid but seeds differ).
var id1 := SessionManager.new_game()
var id2 := SessionManager.new_game()
# Check that hex seeds differ (they almost certainly will)
var seed1 := id1.split("-")[2]
var seed2 := id2.split("-")[2]
assert_str(seed1).override_failure_message(
"Successive new_game() calls should have different RNG seeds"
).is_not_equal(seed2)
# ---------------------------------------------------------------------------
# resume_game() — sets GameState.current_game_id
# ---------------------------------------------------------------------------
func test_resume_game_sets_current_game_id() -> void:
var test_id := "20260225-143022-a7b3f1"
SessionManager.resume_game(test_id)
assert_str(GameState.current_game_id).override_failure_message(
"resume_game() must set GameState.current_game_id to the given id"
).is_equal(test_id)
func test_resume_game_overwrites_previous_game_id() -> void:
SessionManager.resume_game("20260225-100000-aabbcc")
SessionManager.resume_game("20260225-120000-112233")
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
# ---------------------------------------------------------------------------
# Main menu scene
# ---------------------------------------------------------------------------
func test_main_menu_scene_exists() -> void:
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
).is_true()
func test_main_menu_instantiates_without_crash() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
assert_that(scene).is_not_null()
func test_main_menu_has_new_game_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/NewGameBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/NewGameBtn (#258)"
).is_true()
func test_main_menu_has_continue_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/ContinueBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/ContinueBtn (#258)"
).is_true()
# ---------------------------------------------------------------------------
# Project main scene changed to main_menu.tscn
# ---------------------------------------------------------------------------
func test_project_main_scene_is_main_menu() -> void:
## D-085: project boots to main menu, not directly to game scene.
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
assert_str(scene_path).override_failure_message(
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
).is_equal("res://scenes/main_menu.tscn")
+211
View File
@@ -0,0 +1,211 @@
## Sprint 16 #543: GameState.current_zone_id population tests.
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
## current tile after the O(N) → O(1) refactor. Behavior must be identical
## before and after the change (net-zero behavioral change per ticket spec).
## Spec refs: D-073, #543, #529.
class_name TestSnapshotZoneId
extends GdUnitTestSuite
func before_test() -> void:
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.visibility_sectors = {}
# -- Happy path (S16-Z01) ------------------------------------------------------
func test_zone_id_populated_when_player_on_zone_tile() -> void:
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
# D-073: current_zone_id must be set to the server-authoritative zone_id.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"current_zone_id must match zone_id at player tile (5,5)"
).is_equal("zone_alpha")
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
"current_zone_id must be non-empty when player is on a zone-tagged tile"
).is_false()
# -- Missing zone_id field (S16-Z02) -------------------------------------------
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
# Server may send tiles without zone_id when tile is unzoned.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Tile without zone_id field → current_zone_id must default to empty string"
).is_equal("")
# -- Player off-tile (S16-Z03) -------------------------------------------------
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (99,99) with no matching tile → current_zone_id must be empty"
).is_equal("")
# -- Empty tiles (S16-Z04) -----------------------------------------------------
func test_zone_id_empty_on_empty_visible_tiles() -> void:
# S16-Z04: No visible tiles at all → empty string, no crash.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
).is_equal("")
# -- Correct tile selected among many (S16-Z05) --------------------------------
func test_zone_id_selects_correct_tile_among_many() -> void:
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
# Tests that the lookup doesn't accidentally match a neighboring tile.
var tiles: Array = []
for tx in range(10):
for ty in range(10):
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
"zone_id": "zone_%d_%d" % [tx, ty]})
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": tiles,
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (6,4) must get zone_6_4, not a neighboring tile"
).is_equal("zone_6_4")
# -- Zone transition (S16-Z06) -------------------------------------------------
func test_zone_id_updates_on_zone_transition() -> void:
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
# current_zone_id must update on each snapshot.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
GameState.apply_snapshot({
"tick": 2,
"entities": [
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"After moving to (6,5), current_zone_id must update to zone_beta"
).is_equal("zone_beta")
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
# S16-Z07: The O(1) refactor must not break visible_positions population.
# Both zone_id and visible_positions derive from the same visible_tiles loop —
# verify both are correctly populated after a single apply_snapshot().
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
"visible_positions must still contain (5,5) after zone_id refactor"
).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
assert_that(GameState.current_zone_id).override_failure_message(
"current_zone_id must be zone_alpha after same apply_snapshot call"
).is_equal("zone_alpha")
# -- Fractional player position (S16-Z08) --------------------------------------
func test_zone_id_uses_int_truncation_of_player_position() -> void:
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
# Server sends player coords as floats; zone lookup must truncate to tile index.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
).is_equal("zone_alpha")
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
func test_zone_id_works_with_test_mode_tiles_key() -> void:
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
# The O(1) refactor uses member visible_tiles (covers both paths).
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
# block only, this test fails — catching the exact regression Tyre flagged.
GameState.apply_snapshot({
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
],
})
assert_that(GameState.current_zone_id).override_failure_message(
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
).is_equal("zone_alpha")
+292
View File
@@ -0,0 +1,292 @@
## Sprint 16 #540: Sprite integration tests.
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
## bounding box, sprite asset existence from #541, and fog shader independence.
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
class_name TestSpriteIntegration
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
func before_test() -> void:
GameState.player_entity_id = 1
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
# -- Helpers -------------------------------------------------------------------
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
func test_entity_footprint_matches_d044_spec() -> void:
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
"D-044: ENTITY_WIDTH must be 24px"
).is_equal(24)
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
"D-044: ENTITY_HEIGHT must be 32px"
).is_equal(32)
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
# range (2 sim tiles) accurate with the tilted perspective.
var tile_2x: int = Constants.TILE_SIZE * 2
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
EntityRenderer.ENTITY_WIDTH, tile_2x]
).is_true()
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
EntityRenderer.ENTITY_HEIGHT, tile_2x]
).is_true()
func test_entity_width_fits_within_single_tile() -> void:
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
# Ensures horizontal centering offset is positive and entity doesn't overflow.
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
"Entity width must be less than TILE_SIZE for centered layout"
).is_true()
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
).is_true()
# -- Pixel position (S16-S04) -------------------------------------------------
func test_entity_pixel_position_at_tile_3_7() -> void:
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
assert_that(node.position.x).override_failure_message(
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
).is_equal_approx(expected_x, 0.1)
assert_that(node.position.y).override_failure_message(
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
).is_equal_approx(expected_y, 0.1)
renderer.queue_free()
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
# With tilted sprites, south-facing entity must visually overlap northern.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var north_node = renderer.entity_nodes[20]
var south_node = renderer.entity_nodes[21]
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
).is_true()
renderer.queue_free()
func test_z_sort_y_position_difference_equals_tile_size() -> void:
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
# Verifies position calculation is consistent for adjacent tiles.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var node3 = renderer.entity_nodes[30]
var node4 = renderer.entity_nodes[31]
var delta_y := node4.position.y - node3.position.y
assert_that(delta_y).override_failure_message(
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
renderer.queue_free()
func test_z_sort_same_y_different_x_no_y_difference() -> void:
# S16-S07: Two entities at same y but different x → same pixel.y.
# Horizontal position must not affect y-sort order.
var renderer := _make_entity_renderer()
var entities := [
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
]
renderer.update_entities(entities)
var left_node = renderer.entity_nodes[40]
var right_node = renderer.entity_nodes[41]
assert_that(left_node.position.y).override_failure_message(
"Entities at same y-tile must have same pixel.y regardless of x"
).is_equal_approx(right_node.position.y, 0.1)
renderer.queue_free()
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
# entity_renderer.gd must be able to load these paths.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"NPC sprite missing: %s" % path
).is_true()
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
for direction in ["north", "east", "south", "west"]:
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
assert_that(ResourceLoader.exists(path)).override_failure_message(
"Wall sprite missing: %s" % path
).is_true()
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
# the art-direction tilt baked into sprites.
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
"fog_shader.gd must load correctly — must not be affected by sprite changes"
).is_true()
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
"fog.gdshader must exist — fog is screen-space and sprite-independent"
).is_true()
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
var fog = get_node_or_null("/root/FogState")
if fog == null:
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
return
# Provide visibility data but no entity renderer context
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visibility_sectors = {
Vector2i(5, 5): "Forward",
Vector2i(6, 5): "Peripheral",
}
if fog.has_method("update_from_state"):
fog.update_from_state()
assert_that(fog.visibility_texture).override_failure_message(
"FogState visibility_texture must be populated independently of sprite state"
).is_not_null()
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
func test_octant_north_maps_to_north() -> void:
# S16-S12: "North" → "north"
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
func test_octant_northwest_maps_to_north() -> void:
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
func test_octant_northeast_maps_to_east() -> void:
# S16-S14: "Northeast" → "east"
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
func test_octant_east_maps_to_east() -> void:
# S16-S15: "East" → "east"
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
func test_octant_southeast_maps_to_south() -> void:
# S16-S16: "Southeast" → "south"
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
func test_octant_south_maps_to_south() -> void:
# S16-S17: "South" → "south"
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
func test_octant_southwest_maps_to_west() -> void:
# S16-S18: "Southwest" → "west"
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
func test_octant_west_maps_to_west() -> void:
# S16-S19: "West" → "west"
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
func test_octant_unknown_string_falls_back_to_south() -> void:
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
func test_octant_empty_string_falls_back_to_south() -> void:
# S16-S21: Empty string → "south" fallback
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
func test_entity_direction_npc_always_south() -> void:
# S16-S22: NPC entity → always "south" regardless of any data field.
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
# entity_id 99 is not the player
var dir := renderer._entity_direction(99, {"entity_id": 99,
"kind": {"variant": "Npc", "data": null}})
assert_that(dir).override_failure_message(
"NPC entity must always return 'south'"
).is_equal("south")
renderer.queue_free()
func test_entity_direction_player_uses_player_facing() -> void:
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "North"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='North' must return 'north'"
).is_equal("north")
renderer.queue_free()
func test_entity_direction_player_facing_east() -> void:
# S16-S24: Player facing "East" → "east"
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "East"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='East' must return 'east'"
).is_equal("east")
renderer.queue_free()
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
# Diagonal octants map to one of the four cardinal sprite sets.
var renderer := _make_entity_renderer()
GameState.player_entity_id = 1
GameState.player_facing = "Northwest"
var dir := renderer._entity_direction(1, {"entity_id": 1,
"kind": {"variant": "Player", "data": null}})
assert_that(dir).override_failure_message(
"Player entity with player_facing='Northwest' must return 'north'"
).is_equal("north")
renderer.queue_free()
+318
View File
@@ -0,0 +1,318 @@
## Sprint 17 — Time display on insert HUD (#263)
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
##
## Spec refs:
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
## D-051 (diegetic insert display)
##
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
##
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
## reading Label.text via public node paths instead.
class_name TestTimeDisplaySprint17
extends GdUnitTestSuite
var _clock: Control = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.game_time = {}
var ClockScript = load("res://ui/time_display.gd")
_clock = Control.new()
_clock.set_script(ClockScript)
add_child(_clock)
func after_test() -> void:
if _clock and is_instance_valid(_clock):
_clock.queue_free()
_clock = null
GameState.game_time = {}
# -------------------------------------------------------------------------
# Constants.format_game_time() — pure logic, D-031
# -------------------------------------------------------------------------
func test_format_midnight() -> void:
assert_that(Constants.format_game_time(0)).is_equal("00:00")
func test_format_morning_start() -> void:
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
assert_that(Constants.format_game_time(360)).is_equal("06:00")
func test_format_noon() -> void:
assert_that(Constants.format_game_time(720)).is_equal("12:00")
func test_format_evening_start() -> void:
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
func test_format_end_of_day() -> void:
# Last valid minute — must not wrap or overflow
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
func test_format_pads_single_digit_hour() -> void:
# 30 min = 00:30
assert_that(Constants.format_game_time(30)).is_equal("00:30")
func test_format_pads_single_digit_minute() -> void:
# 121 min = 02:01
assert_that(Constants.format_game_time(121)).is_equal("02:01")
func test_format_half_past_hour() -> void:
assert_that(Constants.format_game_time(90)).is_equal("01:30")
func test_format_arbitrary_midday() -> void:
# 835 min = 13:55
assert_that(Constants.format_game_time(835)).is_equal("13:55")
# -------------------------------------------------------------------------
# InsertClock initial state
# -------------------------------------------------------------------------
func test_insert_clock_initial_time_str_is_placeholder() -> void:
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
assert_that(_clock._time_str).is_equal("--:--")
func test_insert_clock_initial_phase_str_is_empty() -> void:
assert_that(_clock._phase_str).is_equal("")
func test_insert_clock_initial_day_str_is_empty() -> void:
assert_that(_clock._day_str).is_equal("")
# -------------------------------------------------------------------------
# InsertClock.update_from_state() — reads GameState.game_time
# -------------------------------------------------------------------------
func test_update_from_state_formats_time_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("12:00")
func test_update_from_state_sets_phase_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_str).is_equal("Evening")
func test_update_from_state_sets_day_str_one_indexed() -> void:
# Day 0 from server → "D1" display (1-indexed)
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D1")
func test_update_from_state_day_2() -> void:
GameState.game_time = {
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D2")
func test_update_from_state_midnight() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("00:00")
func test_update_from_state_end_of_day() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("23:59")
func test_update_from_state_skips_empty_game_time() -> void:
# Empty game_time must not overwrite --:-- (guard in update_from_state)
GameState.game_time = {}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("--:--")
func test_update_from_state_deduplicates_same_tick() -> void:
# Calling twice with identical data must produce same result (signature cache)
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
# Call again — result unchanged, no crash
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
func test_update_from_state_updates_on_new_tick() -> void:
# time_of_day changes → signature changes → _time_str updates
GameState.game_time = {
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("01:00")
GameState.game_time = {
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("02:00")
# -------------------------------------------------------------------------
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
# -------------------------------------------------------------------------
func test_phase_colors_has_morning() -> void:
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
func test_phase_colors_has_afternoon() -> void:
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
func test_phase_colors_has_evening() -> void:
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
func test_phase_colors_has_night() -> void:
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
func test_phase_color_applied_after_update() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
# -------------------------------------------------------------------------
# GameState: game_time field parsing (confirms apply_snapshot wiring)
# -------------------------------------------------------------------------
func test_game_time_populated_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 5, "entities": [],
"game_time": {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
func test_game_time_all_four_phases_store_correctly() -> void:
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
func test_game_time_zero_time_of_day_stored() -> void:
# time_of_day = 0 (midnight) must not be treated as falsy/missing
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
# -------------------------------------------------------------------------
# SimBridge test mode: game_time fields are valid
# -------------------------------------------------------------------------
func test_sim_bridge_snapshot_has_game_time() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.has("game_time")).is_true()
assert_that(snap.game_time is Dictionary).is_true()
func test_sim_bridge_game_time_has_required_fields() -> void:
var snap = SimBridge._test_snapshot()
var gt: Dictionary = snap.game_time
assert_that(gt.has("day")).is_true()
assert_that(gt.has("time_of_day")).is_true()
assert_that(gt.has("day_phase")).is_true()
assert_that(gt.has("tick_rate")).is_true()
func test_sim_bridge_time_of_day_is_non_negative() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
# D-031: 1440 game-minutes per day, valid range 0..1439
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
func test_sim_bridge_day_phase_is_valid() -> void:
var snap = SimBridge._test_snapshot()
var phase: String = snap.game_time.get("day_phase", "")
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
# -------------------------------------------------------------------------
# Scene: InsertClock node at InsertOverlay/TimeDisplay
# -------------------------------------------------------------------------
func test_insert_clock_exists_in_ui_layer() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
func test_insert_clock_time_str_updates_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
GameState.apply_snapshot({
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
assert_that(clock).is_not_null()
assert_that(clock._time_str).is_equal("12:00")
# -------------------------------------------------------------------------
# Regression: debug_overlay still reads game_time correctly (#511)
# -------------------------------------------------------------------------
func test_debug_overlay_reads_game_time_day_phase() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
func test_debug_overlay_reads_game_time_tick_rate() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
})
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
+3 -2
View File
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
func test_minimap_placeholder_exists_in_ui_layer() -> void:
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null()
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
func test_hud_exists_in_ui_layer() -> void:
+99
View File
@@ -0,0 +1,99 @@
## Scene testing utilities for gdUnit4 tests.
##
## Loads a scene, instantiates it into the test suite's node tree,
## and provides helpers for node existence, signal, and node-path queries.
##
## Usage (from a GdUnitTestSuite subclass):
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
## helper.assert_node_exists("World")
## var world := helper.get_node_at("World")
## helper.monitor_signal(world, "ready")
## # ... trigger something ...
## helper.assert_signal_emitted(world, "ready")
##
## Design constraints (D-030): server-free, no running autoload dependencies.
class_name SceneHelper
extends RefCounted
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
var _scene: Node
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
var _signal_hits: Dictionary = {}
## Load, instantiate, and attach a scene to the test suite's node tree.
## The scene node is registered for auto-free by gdUnit4.
## Returns a helper instance; fails the test if the scene cannot be loaded.
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
var helper := SceneHelper.new()
helper._suite = suite
var packed: PackedScene = load(scene_path)
if packed == null:
suite.assert_that(packed).override_failure_message(
"SceneHelper: could not load scene at '%s'" % scene_path
).is_not_null()
return helper
helper._scene = packed.instantiate()
suite.auto_free(helper._scene)
suite.add_child(helper._scene)
return helper
## Returns the scene root node.
func scene() -> Node:
return _scene
## Assert that a node at node_path exists under the scene root.
## Fails the current test if the node is absent.
func assert_node_exists(node_path: String) -> void:
var node := _scene.get_node_or_null(NodePath(node_path))
_suite.assert_that(node).override_failure_message(
"SceneHelper: expected node at path '%s' — not found" % node_path
).is_not_null()
## Return the node at node_path under the scene root, or null if absent.
func get_node_at(node_path: String) -> Node:
return _scene.get_node_or_null(NodePath(node_path))
## Begin tracking emissions of signal_name on node.
## Must be called before the action that triggers the signal.
## Fails the test if node does not have the named signal.
func monitor_signal(node: Node, signal_name: String) -> void:
if not node.has_signal(signal_name):
_suite.assert_that(false).override_failure_message(
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
).is_true()
return
var key := _signal_key(node, signal_name)
_signal_hits[key] = 0
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
# GDScript default-param lambdas handle being called with fewer args correctly.
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
_signal_hits[key] = _signal_hits.get(key, 0) + 1
)
## Assert that signal_name was emitted at least once since monitor_signal().
## Fails the test if monitor_signal() was not called first, or if count is zero.
func assert_signal_emitted(node: Node, signal_name: String) -> void:
var key := _signal_key(node, signal_name)
if not _signal_hits.has(key):
_suite.assert_that(false).override_failure_message(
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
).is_true()
return
var count: int = _signal_hits[key]
_suite.assert_int(count).override_failure_message(
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
signal_name, node.name, count
]
).is_greater(0)
static func _signal_key(node: Node, signal_name: String) -> String:
return "%d:%s" % [node.get_instance_id(), signal_name]
+53 -24
View File
@@ -354,26 +354,45 @@ func _show_options(options: Array) -> void:
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
var numbered_text := "%d. %s" % [i + 1, raw_text]
label.text = numbered_text
# D-063: Confrontation options render italic — first-person voice, weighted differently.
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
var ctrl: Control
if is_confrontation:
var rtl := RichTextLabel.new()
rtl.bbcode_enabled = true
rtl.fit_content = true
rtl.scroll_active = false
rtl.add_theme_font_size_override("normal_font_size", 14)
rtl.add_theme_color_override("default_color", Color(
Constants.INSERT_COLOR_TEXT.r * 1.08,
Constants.INSERT_COLOR_TEXT.g * 0.96,
Constants.INSERT_COLOR_TEXT.b * 0.90,
1.0
)) # Slight warm tint for confrontation weight
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
ctrl = rtl
else:
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
label.text = "%d. %s" % [i + 1, raw_text]
ctrl = label
var idx := i
label.gui_input.connect(func(event: InputEvent):
ctrl.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx)
)
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
options_container.add_child(label)
_option_controls.append(label)
options_container.add_child(ctrl)
_option_controls.append(ctrl)
_option_response_ids.append(opt.get("response_id", ""))
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
@@ -519,7 +538,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]")
return text.replace("[", "[lb]").replace("]", "[rb]")
## Get a stable color for a character name, with contrast floor enforcement.
@@ -575,19 +594,22 @@ func _expire_entries() -> void:
var removed := false
var has_fading := false
# Remove expired non-pinned entries from the front (oldest first)
while _log_entries.size() > 0:
var entry: Dictionary = _log_entries[0]
# Remove expired non-pinned entries (oldest first, skipping pinned)
var i := 0
while i < _log_entries.size():
var entry: Dictionary = _log_entries[i]
if entry.pinned:
break # Pinned entries never expire
i += 1
continue # Pinned entries never expire — keep scanning
var age: int = now - entry.timestamp_msec
if age < total_lifetime_msec:
# Check if this entry is in the fading phase
if age > int(_entry_lifetime * 1000.0):
has_fading = true
break
_log_entries.remove_at(0)
_log_entries.remove_at(i)
removed = true
# Don't increment i — element at i is now the next entry
# Check remaining entries for fading state
if not has_fading:
@@ -623,11 +645,18 @@ func _clear_options() -> void:
# Hover callbacks
static func _make_hover_on(label: Control) -> Callable:
static func _make_hover_on(ctrl: Control) -> Callable:
return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
# RichTextLabel uses "default_color"; Label uses "font_color"
if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
static func _make_hover_off(label: Control) -> Callable:
static func _make_hover_off(ctrl: Control) -> Callable:
return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
+88
View File
@@ -0,0 +1,88 @@
extends Control
## Examine result display — #174, D-061 adjacent.
##
## Shows the character-filtered text returned by the Examine verb (#242).
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
## Diegetic: reads as the neural insert processing what the character observed.
##
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
## Only one examine result is shown at a time — new result replaces old.
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.35
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
const CONFIDENCE_ALPHA: Dictionary = {
"Direct": 1.0,
"KnowsDetails": 0.9,
"KnowsOf": 0.75,
"Suspects": 0.6,
}
@onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
var _dismiss_tween: Tween = null
var _active: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
modulate.a = 0.0
visible = false
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
## result: {entity_id, text, confidence}
func show_result(result: Dictionary) -> void:
var text: String = result.get("text", "")
var confidence: String = result.get("confidence", "KnowsOf")
if text.is_empty():
return
# Cancel any in-progress dismiss
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
# Apply confidence-based alpha to the insert color
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
Constants.INSERT_COLOR_TEXT.b, alpha)
text_label.add_theme_color_override("default_color", col)
text_label.text = text
visible = true
_active = true
modulate.a = 0.0
# Fade in, then auto-dismiss
_dismiss_tween = create_tween()
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
_dismiss_tween.tween_interval(DISMISS_DELAY)
_dismiss_tween.tween_callback(_start_fade_out)
func _start_fade_out() -> void:
if not _active:
return
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func():
visible = false
_active = false
)
## Dismiss immediately (e.g. when dialogue opens).
func dismiss() -> void:
if not _active:
return
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
func is_active() -> bool:
return _active
+50
View File
@@ -0,0 +1,50 @@
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
[node name="ExamineDisplay" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 0.0
offset_left = -440.0
offset_top = 120.0
offset_right = -16.0
offset_bottom = 240.0
grow_horizontal = 0
grow_vertical = 2
mouse_filter = 2
modulate = Color(1, 1, 1, 0)
script = ExtResource("1_examine")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
mouse_filter = 2
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
fit_content = true
scroll_active = false
mouse_filter = 2
theme_override_font_sizes/normal_font_size = 13
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)
+26 -1
View File
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
var _verb_labels: Array[Label] = []
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
@onready var _vbox: VBoxContainer = $VBox
@@ -39,10 +41,14 @@ func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
var _last_screen_pos: Vector2 = Vector2.ZERO
func _process(_delta: float) -> void:
if _showing:
_update_screen_position()
queue_redraw()
if position != _last_screen_pos:
_last_screen_pos = position
queue_redraw()
func _draw() -> void:
@@ -52,6 +58,9 @@ func _draw() -> void:
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
draw_rect(bg_rect, INSERT_BG)
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
func update_from_state() -> void:
@@ -88,6 +97,7 @@ func update_from_state() -> void:
_verb_items = sorted
_selected_index = 0
_cache_entity_position()
_cache_entity_relationship()
_rebuild_labels()
_show()
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
return
## #537: Cache relationship color for the target entity (D-033 palette).
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
func _cache_entity_relationship() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
var rel: String = str(entity.get("relationship", "Unknown"))
_relationship_color = Constants.color_for_relationship(rel)
return
_relationship_color = Constants.IMPLANT_TEXT_DIM
## Convert entity world position to screen coords and reposition this Control.
## Runs every frame while showing so the list tracks the entity as the camera moves.
func _update_screen_position() -> void:
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
return _verb_items.size()
func hide_list() -> void:
_hide()
func is_showing() -> bool:
return _showing
+214
View File
@@ -0,0 +1,214 @@
extends Control
## Journal panel — knowledge graph review. #264, D-041.
##
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
## grouped by entity. Read-only — no player interaction beyond scrolling.
##
## KnowledgeState rendering:
## Active → normal color (INSERT_COLOR_TEXT)
## Stale → dimmed (IMPLANT_TEXT_DIM)
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
##
## KnowledgeConfidence labels (D-041):
## Direct → KnowsDetails → KnowsOf → Suspects
##
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
## Closes when dialogue opens.
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.25
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
# Fallback: raw value if key not found (UIStrings returns the key itself).
@onready var panel: PanelContainer = $PanelContainer
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
var _visible_state: bool = false
var _last_rendered_tick: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
modulate.a = 0.0
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
## Toggle open/close. Called from main.gd on J key press.
func toggle() -> void:
if _visible_state:
_hide_panel()
else:
_show_panel()
## Force-close. Called when dialogue opens.
func close() -> void:
if _visible_state:
_hide_panel()
func is_open() -> bool:
return _visible_state
func _show_panel() -> void:
_rebuild_entries()
_visible_state = true
visible = true
var t := create_tween()
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide_panel() -> void:
_visible_state = false
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
## Rebuild the entry list from GameState.player_knowledge.
func _rebuild_entries() -> void:
# Clear previous entries
for child in entries_container.get_children():
child.queue_free()
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
_add_empty_state()
return
var entities: Array = knowledge.get("entities", [])
if entities.is_empty():
_add_empty_state()
return
for entity in entities:
if not entity is Dictionary:
continue
_add_entity_entry(entity)
func _add_empty_state() -> void:
var label := Label.new()
label.text = UIStrings.get_text("knowledge_panel.empty_state")
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
label.add_theme_font_size_override("font_size", 13)
entries_container.add_child(label)
func _add_entity_entry(entity: Dictionary) -> void:
var name_str: String = entity.get("name", "Unknown")
var confidence: String = entity.get("confidence", "Suspects")
var source: String = entity.get("source", "")
var state: String = entity.get("state", "Active")
var relationship: String = entity.get("relationship", "Unknown")
# Entity header — name + relationship status
var header_rtl := RichTextLabel.new()
header_rtl.bbcode_enabled = true
header_rtl.fit_content = true
header_rtl.scroll_active = false
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
header_rtl.add_theme_font_size_override("normal_font_size", 14)
var rel_color: Color = Constants.color_for_relationship(relationship)
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
rel_label = relationship # fallback if key missing
var state_color: Color = _state_color(state)
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
var src_label: String = _resolve_source_label(source)
# Build BBCode:
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
var name_hex: String = state_color.to_html(false)
var rel_hex: String = rel_color.to_html(false)
var header_bbcode: String
if state == "Contradicted":
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
name_hex, name_str, rel_hex, rel_label
]
else:
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
name_hex, name_str, rel_hex, rel_label
]
header_rtl.text = header_bbcode
entries_container.add_child(header_rtl)
# Detail line — confidence + source
var detail_rtl := RichTextLabel.new()
detail_rtl.bbcode_enabled = true
detail_rtl.fit_content = true
detail_rtl.scroll_active = false
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
entries_container.add_child(detail_rtl)
# Spacer between entries
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
entries_container.add_child(spacer)
## Resolve a source string from the server into a human-readable label.
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
func _resolve_source_label(source: String) -> String:
if source.is_empty():
return ""
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
if source.begins_with("ToldBy(") and source.ends_with(")"):
var id_str: String = source.substr(7, source.length() - 8)
var entity_id: int = id_str.to_int()
var name := _entity_name_for_id(entity_id)
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
if told_prefix == "knowledge_panel.source_toldby":
told_prefix = "Told"
return "%s: %s" % [told_prefix, name]
# Plain source key — look up UIStrings
var key: String = "knowledge_panel.source_%s" % source.to_lower()
return UIStrings.get_text(key)
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
func _entity_name_for_id(entity_id: int) -> String:
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
return "#%d" % entity_id
for entity in knowledge.get("entities", []):
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
return entity.get("name", "#%d" % entity_id)
return "#%d" % entity_id
func _state_color(state: String) -> Color:
match state:
"Contradicted":
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
"Stale":
return Constants.IMPLANT_TEXT_DIM # dimmed
_:
return Constants.INSERT_COLOR_TEXT # active — normal
## Called from main.gd on each snapshot to close the panel when dialogue opens.
func update_from_state() -> void:
if _visible_state and GameState.dialogue_active:
close()
# Rebuild if open and knowledge data is newer than last render
if _visible_state and GameState.current_tick != _last_rendered_tick:
_last_rendered_tick = GameState.current_tick
_rebuild_entries()
+63
View File
@@ -0,0 +1,63 @@
[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"]
[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"]
; JournalPanel — knowledge graph review. #264, D-041.
; Toggle J key. Right side, 380px wide, full height minus margins.
; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue.
[node name="JournalPanel" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -400.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 0
grow_vertical = 2
mouse_filter = 2
modulate = Color(1, 1, 1, 0)
script = ExtResource("1_journal")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 14
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 14
theme_override_constants/margin_bottom = 12
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Contacts"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9)
[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2)
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 2
+50
View File
@@ -0,0 +1,50 @@
extends Control
## #258: Main menu — New Game / Continue / Quit.
## New Game: generates per-game save directory (D-085), starts game.
## Continue: loads most recent save directory (#554 full loading screen deferred).
const GAME_SCENE := "res://scenes/main.tscn"
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
const TITLE_COLOR := Color("#c8d0e0")
const SUBTITLE_COLOR := Color("#8890a0")
const BTN_NORMAL_COLOR := Color("#e8c547")
const BTN_DISABLED_COLOR := Color("#4a5060")
const FONT_SIZE_TITLE := 36
const FONT_SIZE_SUBTITLE := 14
const FONT_SIZE_BTN := 15
@onready var _new_game_btn: Button = $VBox/NewGameBtn
@onready var _continue_btn: Button = $VBox/ContinueBtn
@onready var _quit_btn: Button = $VBox/QuitBtn
func _ready() -> void:
_new_game_btn.pressed.connect(_on_new_game)
_continue_btn.pressed.connect(_on_continue)
_quit_btn.pressed.connect(_on_quit)
_refresh_continue_state()
func _refresh_continue_state() -> void:
var saves := SessionManager.list_game_dirs()
_continue_btn.disabled = saves.is_empty()
func _on_new_game() -> void:
SessionManager.new_game()
get_tree().change_scene_to_file(GAME_SCENE)
func _on_continue() -> void:
# #554: Full loading screen blocked until server SaveCommand lands.
# For now: automatically load the most recent game directory.
var saves := SessionManager.list_game_dirs()
if saves.is_empty():
return
SessionManager.resume_game(saves[0].game_id)
get_tree().change_scene_to_file(GAME_SCENE)
func _on_quit() -> void:
get_tree().quit()
+166 -9
View File
@@ -1,15 +1,172 @@
class_name MinimapRenderer
extends Control
# Minimap — small viewport showing local area
# Placeholder implementation for now
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
##
## Player always centered. Fixed-north (no rotation per D-015).
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
## Frame renders always — the insert is on even when no POIs are discovered.
##
## POI categories → shapes:
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
## (default) → circle (INSERT_COLOR_TEXT)
@onready var viewport_container: SubViewportContainer = $SubViewportContainer
## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
const MINIMAP_RADIUS: float = 24.0
# Visual parameters
const FRAME_WIDTH: float = 1.2
const PLAYER_DOT_RADIUS: float = 3.5
const POI_DOT_RADIUS: float = 3.0
const ARROW_HALF: float = 4.5
const ARROW_LEN: float = 7.0
const NORTH_TICK_LEN: float = 8.0
const CARDINAL_TICK_LEN: float = 4.0
# Colors — insert palette from Constants, tuned for the circular minimap frame
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
var _insert_active: bool = true
func _ready() -> void:
print("Minimap: Initialized")
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
# Update minimap view (stub)
func update_minimap(player_pos: Vector2, entities: Array) -> void:
# TODO: Render minimap view
# This will show a top-down view of the local area
pass
func _process(_delta: float) -> void:
if _insert_active:
queue_redraw()
## Called from main.gd when GameState.insert_active changes.
## Hides the minimap overlay when the neural insert is inactive.
func set_insert_active(active: bool) -> void:
_insert_active = active
visible = active
func _draw() -> void:
var sz: Vector2 = get_rect().size
var center := sz / 2.0
# Outer radius: fill control with 1px edge padding
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
# --- Background fill ---
draw_circle(center, outer_r, COLOR_BG)
# --- Frame ring ---
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
_draw_cardinal_ticks(center, outer_r)
# --- Player dot at center ---
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
# Soft bloom ring
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
# --- POIs ---
var pois: Array = GameState.discovered_pois
if pois.is_empty():
return
var px: float = GameState.player_position.x
var py: float = GameState.player_position.y
# Pixels per sim tile within the inner drawable area
var inner_r: float = outer_r - FRAME_WIDTH
var scale: float = inner_r / MINIMAP_RADIUS
for poi in pois:
if not poi is Dictionary:
continue
if not poi.has("x") or not poi.has("y"):
continue
var dx: float = float(poi.x) - px
var dy: float = float(poi.y) - py
var dist: float = sqrt(dx * dx + dy * dy)
var category: String = poi.get("category", "")
var color: Color = _category_color(category)
if dist < 0.01:
# POI at exact player position — draw at center offset slightly
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
elif dist <= MINIMAP_RADIUS:
# Nearby: project to screen position within circle
var poi_screen := center + Vector2(dx, dy) * scale
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
var rel := poi_screen - center
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
_draw_poi_shape(poi_screen, color, category)
else:
# Distant: arrow at border pointing toward POI direction
var dir := Vector2(dx, dy).normalized()
var arrow_tip := center + dir * (inner_r - 2.0)
_draw_border_arrow(arrow_tip, dir, color)
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
# North tick — longer, brighter, the fixed-north indicator
var n_dir := Vector2(0.0, -1.0)
draw_line(
center + n_dir * (outer_r - NORTH_TICK_LEN),
center + n_dir * outer_r,
COLOR_NORTH, FRAME_WIDTH + 0.5, true
)
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
var dir := Vector2(cos(angle), sin(angle))
draw_line(
center + dir * (outer_r - CARDINAL_TICK_LEN),
center + dir * outer_r,
COLOR_CARDINAL, FRAME_WIDTH, true
)
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
match category.to_lower():
"danger", "threat", "hostile":
# Diamond for danger
var s: float = POI_DOT_RADIUS + 1.0
draw_polygon(
PackedVector2Array([
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
]),
PackedColorArray([color, color, color, color])
)
"evidence", "note", "clue":
# Square for evidence/clue
var s: float = POI_DOT_RADIUS - 0.5
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
_:
draw_circle(pos, POI_DOT_RADIUS, color)
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := tip - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
func _category_color(category: String) -> Color:
match category.to_lower():
"danger", "threat", "hostile":
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
"evidence", "note", "clue":
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
"contact", "npc", "person":
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
_:
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
+11 -23
View File
@@ -2,33 +2,21 @@
[ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"]
[node name="Minimap" type="Control"]
; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6).
; 160x160px circle, top-right corner with 16px margin.
; Rendered via _draw() — no SubViewport needed.
; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn.
[node name="MinimapRenderer" type="Control"]
custom_minimum_size = Vector2(160, 160)
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -200.0
offset_bottom = 200.0
offset_left = -176.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 176.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_minimap")
[node name="SubViewportContainer" type="SubViewportContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"]
size = Vector2i(200, 200)
render_target_update_mode = 4
[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.1, 0.1, 0.1, 0.7)
+13
View File
@@ -121,6 +121,14 @@ func _build_ui() -> void:
close_btn.pressed.connect(close)
_container.add_child(close_btn)
# Quit to Menu button (#258: D-085 session management)
var quit_btn := Button.new()
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
quit_btn.pressed.connect(_on_quit_to_menu)
_container.add_child(quit_btn)
func _destroy_ui() -> void:
if _container:
@@ -153,6 +161,11 @@ func _draw() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
func _on_quit_to_menu() -> void:
close()
SessionManager.quit_to_menu(get_tree().root)
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
+97
View File
@@ -0,0 +1,97 @@
extends Control
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
## Shows station local time (HH:MM), day phase, and day number.
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
##
## Placeholder layout — position and style will be refined when #314 wireframe lands.
const FONT_SIZE_TIME: int = 15
const FONT_SIZE_META: int = 10
const PADDING := Vector2(10, 7)
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
# Day phase colors — station lighting cycle (D-031)
const PHASE_COLORS := {
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
"Night": Color("#4a7080"), # dark teal — station nightwatch
}
var _time_str: String = "--:--"
var _phase_str: String = ""
var _day_str: String = ""
var _day_text: String = ""
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
var _last_signature: String = ""
var _has_data: bool = false
# Cached geometry — recomputed in update_from_state(), used in _draw()
var _time_size: Vector2 = Vector2.ZERO
var _phase_size: Vector2 = Vector2.ZERO
var _day_size: Vector2 = Vector2.ZERO
var _meta_h: float = 0.0
var _box_w: float = 0.0
var _box_h: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var gt: Dictionary = GameState.game_time
if gt.is_empty():
return
var tod: int = int(gt.get("time_of_day", 0))
var day: int = int(gt.get("day", 0))
var phase: String = str(gt.get("day_phase", ""))
var sig: String = "%d:%d:%s" % [tod, day, phase]
if sig == _last_signature:
return
_last_signature = sig
_time_str = Constants.format_game_time(tod)
_phase_str = phase
_day_str = "D%d" % (day + 1)
_day_text = " " + _day_str
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
_has_data = true
_cache_geometry()
queue_redraw()
func _cache_geometry() -> void:
var font := ThemeDB.fallback_font
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
func _draw() -> void:
if not _has_data:
return
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
var font := ThemeDB.fallback_font
# HH:MM (primary, full brightness)
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
# Phase + day number (secondary, dimmed + phase-tinted)
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
draw_string(font, Vector2(PADDING.x, meta_y),
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
+22
View File
@@ -0,0 +1,22 @@
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
; #263: Time display — top-left placeholder per D-013/D-051.
; Position and size will be refined when #314 wireframe lands.
; NOTE: draw-based content manages its own layout; Control rect is a
; minimum bounding box, not a clip rect. Increase if content grows.
[node name="TimeDisplay" type="Control"]
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 16.0
offset_top = 16.0
offset_right = 200.0
offset_bottom = 70.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_tdisplay")
+14 -14
View File
@@ -253,7 +253,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
| Tag | Type | Format | Description |
|-----|------|--------|-------------|
| `id` | string | `{location}_{d}_{###}` | Stable line ID. Machine-parseable. Never reused. |
| `id` | string | `{npc-slug}_d_{###}` | Stable line ID. Machine-parseable. Never reused. NPC-scoped per D-035 Sprint 15 amendment. |
| `text` | string | The line | The authored dialogue text |
| `role` | enum | lowercase-slug | Template role, not NPC name. NPCs fill roles at runtime. |
| `access` | list | `[public, insider, ...]` | D-028 Layer 1 hard filter. Who can hear this. |
@@ -265,7 +265,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
| Tag | Type | Format | Description |
|-----|------|--------|-------------|
| `topic` | list | `[colleague, cargo, ...]` | D-028 Layer 4 weighted selection. Defaults to `[routine]`. |
| `mood` | list | `[comfortable, worried, ...]` | D-028 Layer 4 weighted selection. Defaults to `[comfortable]`. |
| `mood` | list | `[content, anxious, ...]` | D-028 Layer 4 weighted selection. Defaults to untagged (neutral). |
| `tags` | list | freeform strings | Escape hatch for author intent. |
#### Authoring-Only Tags (Not Consumed by Engine)
@@ -352,7 +352,7 @@ Same trigger, different pool. That's how mirror moments work.
| Tag | Type | Description |
|-----|------|-------------|
| `id` | string | `{location}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective |
| `id` | string | `{npc-slug}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective. NPC-scoped per D-035 Sprint 15 amendment. |
| `text` | string | The monologue line (160 char max) |
| `character` | enum | `smuggler` or `detective` |
| `trigger` | enum | What causes this line to fire (see trigger types below) |
@@ -600,9 +600,9 @@ See `content/global/enums/topics.yaml`. Note: `crime` is deliberately excluded.
### Moods (8 values)
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
`anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
See `content/global/enums/moods.yaml`.
See `content/global/enums/moods.yaml`. (Updated Sprint 14 amendment to D-035.)
### Monologue Triggers (9 values)
@@ -630,7 +630,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
### Dialogue Lines
- [ ] **HARD:** `id` matches pattern `{location}_d_{###}` (lowercase, underscore-separated)
- [ ] **HARD:** `id` matches pattern `{npc-slug}_d_{###}` (e.g. `kael-davan_d_001`) — NPC-scoped per D-035 Sprint 15 amendment
- [ ] **HARD:** `role` is a valid lowercase slug matching a template role
- [ ] **HARD:** `access` is a non-empty list of valid access tier enums
- [ ] **HARD:** `trust` is one of `surface`, `real`, `secret`
@@ -643,7 +643,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
### Monologue Lines
- [ ] **HARD:** `id` matches pattern `{location}_m_{s|d}_{###}`
- [ ] **HARD:** `id` matches pattern `{npc-slug}_m_{s|d}_{###}` (e.g. `pc-smuggler_m_s_001`) — NPC-scoped per D-035 Sprint 15 amendment
- [ ] **HARD:** `text` is 160 characters or fewer
- [ ] **HARD:** `trigger` is a valid trigger enum
- [ ] **HARD:** Character partition: smuggler lines in smuggler files, detective in detective files
@@ -755,9 +755,9 @@ All YAML content files must validate against their JSON Schema:
| Content Type | Pattern | Example |
|-------------|---------|---------|
| Dialogue | `{location}_d_{###}` | `transit_d_044` |
| Monologue (smuggler) | `{location}_m_s_{###}` | `transit_m_s_011` |
| Monologue (detective) | `{location}_m_d_{###}` | `transit_m_d_042` |
| Dialogue | `{npc-slug}_d_{###}` | `kael-davan_d_001` |
| Monologue (smuggler) | `{npc-slug}_m_s_{###}` | `pc-smuggler_m_s_001` |
| Monologue (detective) | `{npc-slug}_m_d_{###}` | `pc-detective_m_d_001` |
IDs are stable identifiers. Once assigned, never reused or reassigned. Number gaps are acceptable.
@@ -865,26 +865,26 @@ Every generated line must pass:
### Step 2: Assign Tags
```yaml
- id: transit_d_071
- id: kael-davan_d_076
text: "Morning. Voss kept the rotation thin -- we've got a clear window after 14:00."
role: dock-worker
access: [insider]
trust: real
situation: [shift_start]
topic: [cargo, routine]
mood: [comfortable]
mood: [content]
tags: [ring-operational]
```
### Step 3: Validate
- `id`: `transit_d_071` -- valid pattern.
- `id`: `kael-davan_d_076` -- valid pattern (NPC-scoped per D-035 Sprint 15 amendment). Continues from kael-davan's terminal sequence (_034-_075).
- `role`: `dock-worker` -- valid slug.
- `access: [insider]` -- only ring members hear this. Detective never sees it. Correct.
- `trust: real` -- requires earned trust. Not surface-level small talk. Correct for operational ring dialogue.
- `situation: [shift_start]` -- morning shift context. Correct.
- `topic: [cargo, routine]` -- covers both operational and schedule content. Correct.
- `mood: [comfortable]` -- Phase 1, baseline warm. Correct.
- `mood: [content]` -- Phase 1, baseline warm. Correct.
### Step 4: Dual-Lens Check
+1 -1
View File
@@ -10,7 +10,7 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
"description": "Stable machine-parseable line ID: {npc-slug}_{d|m}_{###} (e.g. kael-davan_d_001, pc-detective_m_d_001). d = dialogue, m = monologue. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
+1 -1
View File
@@ -32,7 +32,7 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_d_{###}"
"description": "Stable line ID: {npc-slug}_d_{###} (e.g. kael-davan_d_001). NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
+1 -1
View File
@@ -32,7 +32,7 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
"description": "Stable line ID: {npc-slug}_m_{s|d}_{###} (e.g. pc-smuggler_m_s_001, pc-detective_m_d_001). s = smuggler, d = detective. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
},
"text": {
"type": "string",
@@ -13,7 +13,7 @@ lines:
# RING OPERATIONS — corridor handoffs
# ========================================
- id: maintenance-corridors_d_001
- id: kael-davan_d_001
text: "Package is in the junction locker. Renn picks it up in ten."
role: dock-worker
access: [insider]
@@ -23,7 +23,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, operational]
- id: maintenance-corridors_d_002
- id: kael-davan_d_002
text: "B-7 is clear. Camera cycles in forty seconds — move fast."
role: dock-worker
access: [insider]
@@ -35,7 +35,7 @@ lines:
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_003
- id: kael-davan_d_003
text: "Chalk marks are fresh. Renn came through already. We're on schedule."
role: dock-worker
access: [insider]
@@ -45,7 +45,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, operational]
- id: maintenance-corridors_d_004
- id: kael-davan_d_004
text: "Don't use the C-section hatch tonight. Maintenance crew's working late."
role: dock-worker
access: [insider]
@@ -60,7 +60,7 @@ lines:
# When the smuggler finds Kael with the unknown contact
# ========================================
- id: maintenance-corridors_d_005
- id: kael-davan_d_005
text: "What are you — wait. How long have you been standing there?"
role: dock-worker
access: [insider]
@@ -70,7 +70,7 @@ lines:
topic: [danger]
tags: [kael, contradiction, phase-3]
- id: maintenance-corridors_d_006
- id: kael-davan_d_006
text: "That was nobody. Maintenance contact. It's handled."
role: dock-worker
access: [insider]
@@ -80,7 +80,7 @@ lines:
topic: [routine]
tags: [kael, deflection, lying, phase-4]
- id: maintenance-corridors_d_007
- id: kael-davan_d_007
text: "I don't have to explain every conversation I have. Drop it."
role: dock-worker
access: [insider]
@@ -94,7 +94,7 @@ lines:
# SECRET — corridor confessions
# ========================================
- id: maintenance-corridors_d_008
- id: kael-davan_d_008
text: "I'm trying to get out. That's all. I just want out."
role: dock-worker
access: [insider]
@@ -104,7 +104,7 @@ lines:
topic: [personal, trust]
tags: [kael, confession, phase-5]
- id: maintenance-corridors_d_009
- id: kael-davan_d_009
text: "Nils will kill me if he finds out. You know that. You know him."
role: dock-worker
access: [insider]
@@ -114,7 +114,7 @@ lines:
topic: [danger, trust]
tags: [kael, confession, nils, phase-5]
- id: maintenance-corridors_d_010
- id: kael-davan_d_010
text: "I found someone who can get me new papers. Off-station. Clean break."
role: dock-worker
access: [insider]
@@ -136,7 +136,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. Any stranger in restricted corridors is a problem.
- id: maintenance-corridors_d_011
- id: kael-davan_d_011
text: "This is a restricted section. Who authorized you back here?"
role: dock-worker
access: [public]
@@ -148,7 +148,7 @@ lines:
notes: "Layer 2 greeting — first meeting in restricted corridors. Immediate challenge. Any stranger back here is suspicious regardless of intent."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: maintenance-corridors_d_012
- id: kael-davan_d_012
text: "You're on time. Clear all the way to the junction."
role: dock-worker
access: [peer, insider]
@@ -160,7 +160,7 @@ lines:
notes: "Layer 2 greeting — established, operational. All business. Kael treats a trusted repeat visitor as crew — no pleasantries, just the essential handoff information."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: maintenance-corridors_d_013
- id: kael-davan_d_013
text: "You keep showing up back here. You have clearance for this section?"
role: dock-worker
access: [authority]
@@ -172,7 +172,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure in restricted section. Professionally defensive. Kael challenges them but can't bar entry if they have clearance."
# post-confrontation: confrontation logged. Kael is cornered on his own operational ground.
- id: maintenance-corridors_d_014
- id: kael-davan_d_014
text: "You followed me here."
role: dock-worker
access: [peer, insider]
@@ -21,7 +21,7 @@ lines:
# SURFACE COVER — sounds like maintenance work
# ==========================================
- id: maintenance-corridors_d_100
- id: ring-operative_d_001
text: "Running maintenance on the B-7 conduit. Could be a while."
role: ring-operative
access: [public, peer]
@@ -35,7 +35,7 @@ lines:
detective: "Maintenance worker. Conduit repair. Routine."
notes: "'Running maintenance' is the canonical ring cover phrase."
- id: maintenance-corridors_d_101
- id: ring-operative_d_002
text: "Bay four access from here is faster than going around. If you're in a hurry."
role: ring-operative
access: [public, peer]
@@ -48,7 +48,7 @@ lines:
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
- id: ring-operative_d_003
text: "Quiet in here tonight. Good working conditions."
role: ring-operative
access: [public, peer]
@@ -61,7 +61,7 @@ lines:
smuggler: "Dead air confirmed. Meridian's not picking this up."
detective: "Routine corridor comment."
- id: maintenance-corridors_d_103
- id: ring-operative_d_004
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
role: ring-operative
access: [public, peer]
@@ -74,7 +74,7 @@ lines:
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
- id: ring-operative_d_005
text: "C-4 access is at the far end. About three minutes from here."
role: ring-operative
access: [public, peer]
@@ -94,7 +94,7 @@ lines:
# OPERATIONAL COORDINATION — insider/real only
# ==========================================
- id: maintenance-corridors_d_105
- id: ring-operative_d_006
text: "Cans are ready. Bay four, per the schedule."
role: ring-operative
access: [insider]
@@ -107,7 +107,7 @@ lines:
smuggler: "Containers cleared. Bay four. We're on."
detective: "Never hears this line."
- id: maintenance-corridors_d_106
- id: ring-operative_d_007
text: "Manifest is clean. Window opens in eighteen minutes."
role: ring-operative
access: [insider]
@@ -120,7 +120,7 @@ lines:
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
detective: "Never hears this line."
- id: maintenance-corridors_d_107
- id: ring-operative_d_008
text: "Usual pickup. Route's the same."
role: ring-operative
access: [insider]
@@ -133,7 +133,7 @@ lines:
smuggler: "Same route, same handoff. No variations."
detective: "Never hears this line."
- id: maintenance-corridors_d_108
- id: ring-operative_d_009
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
role: ring-operative
access: [insider]
@@ -146,10 +146,10 @@ lines:
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_109
- id: ring-operative_d_010
text: "Drin was asking about B-7. Twice today."
role: ring-operative
access: [insider]
@@ -165,7 +165,7 @@ lines:
fact_id: investigation.drin_inspection_pattern
confidence: suspects
- id: maintenance-corridors_d_110
- id: ring-operative_d_011
text: "Dead air through here to the C-4 junction. No coverage."
role: ring-operative
access: [insider]
@@ -178,10 +178,10 @@ lines:
smuggler: "Full confirmation. Meridian doesn't reach this section."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
fact_id: investigation.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_111
- id: ring-operative_d_012
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
role: ring-operative
access: [insider]
@@ -194,7 +194,7 @@ lines:
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
- id: ring-operative_d_013
text: "Kael's on dock. He knows."
role: ring-operative
access: [insider]
@@ -211,7 +211,7 @@ lines:
# PRESSURE / WARNING SIGNALS
# ==========================================
- id: maintenance-corridors_d_113
- id: ring-operative_d_014
text: "Commission eyes on the terminal floor. Not here yet."
role: ring-operative
access: [insider]
@@ -224,7 +224,7 @@ lines:
smuggler: "Detective is visible on the main floor. Corridors are still clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_114
- id: ring-operative_d_015
text: "Hold the run. Drin's in the corridor section."
role: ring-operative
access: [insider]
@@ -237,7 +237,7 @@ lines:
smuggler: "Stop. Drin's in position. The window's closed."
detective: "Never hears this line."
- id: maintenance-corridors_d_115
- id: ring-operative_d_016
text: "Maret filed again. Voss has it. Don't move anything tonight."
role: ring-operative
access: [insider]
@@ -250,7 +250,7 @@ lines:
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
detective: "Never hears this line."
- id: maintenance-corridors_d_116
- id: ring-operative_d_017
text: "We're clean. Same time tomorrow."
role: ring-operative
access: [insider]
@@ -267,7 +267,7 @@ lines:
# IF DETECTIVE SOMEHOW ENTERS — surface only
# ==========================================
- id: maintenance-corridors_d_117
- id: ring-operative_d_018
text: "Restricted access back here. Service personnel only."
role: ring-operative
access: [authority, public]
@@ -277,7 +277,7 @@ lines:
mood: [suspicious]
tags: [ring-operative, detective-path, access-denial]
- id: maintenance-corridors_d_118
- id: ring-operative_d_019
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
role: ring-operative
access: [authority]
@@ -290,7 +290,7 @@ lines:
smuggler: "Never encounters this."
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
- id: maintenance-corridors_d_119
- id: ring-operative_d_020
text: "Nothing unusual back here. Conduit work. Same every cycle."
role: ring-operative
access: [authority, public]
@@ -305,7 +305,7 @@ lines:
# Surface-only: covers routine corridor presence without operational tells.
# ==========================================
- id: maintenance-corridors_d_120
- id: ring-operative_d_021
text: "Power relay's been cycling weird. I'm watching it."
role: ring-operative
access: [public, peer]
@@ -315,7 +315,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_121
- id: ring-operative_d_022
text: "Pael was supposed to handle B-7. He didn't show."
role: ring-operative
access: [public, peer]
@@ -325,7 +325,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, pael, generation-pass]
- id: maintenance-corridors_d_122
- id: ring-operative_d_023
text: "Watch the floor here. Drain panel's been loose three cycles."
role: ring-operative
access: [public, peer]
@@ -335,7 +335,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_123
- id: ring-operative_d_024
text: "Airflow's better down the B-section. C-section's got the recycler issue."
role: ring-operative
access: [public, peer]
@@ -345,7 +345,7 @@ lines:
mood: [content]
tags: [ring-operative, surface-cover, spatial, generation-pass]
- id: maintenance-corridors_d_124
- id: ring-operative_d_025
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]
@@ -358,7 +358,7 @@ lines:
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
- id: ring-operative_d_026
text: "Long shift. These corridors look the same after a while."
role: ring-operative
access: [public, peer]
@@ -368,7 +368,7 @@ lines:
mood: [frustrated]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_126
- id: ring-operative_d_027
text: "Scan's clear. I checked it an hour ago."
role: ring-operative
access: [public, peer]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: transit-worker_d_001 — sounds like surveillance positioning inquiry
# Actually: Tev missed Kosse at the corridor junction and is looking for them
# Ticket: #307 | Sprint: 12
@@ -15,7 +15,7 @@ lines:
# Actually: Tev missed his partner at the regular meeting spot.
# ==========================================
- id: maintenance-corridors_d_001
- id: transit-worker_d_001
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
role: transit-worker
access: [public]
@@ -36,7 +36,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: maintenance-corridors_d_002
- id: transit-worker_d_002
text: "They shifted the freight route again. Always forget which junction."
role: transit-worker
access: [public]
@@ -46,7 +46,7 @@ lines:
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_003
- id: transit-worker_d_003
text: "Just on break. Out of your way in a minute."
role: transit-worker
access: [public]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [tev, incidental]
- id: maintenance-corridors_d_004
- id: transit-worker_d_004
text: "Late freight route runs long. Can't do anything about it."
role: transit-worker
access: [public]
@@ -66,7 +66,7 @@ lines:
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_005
- id: transit-worker_d_005
text: "Waiting for someone. Shouldn't be long."
role: transit-worker
access: [public]
@@ -1,5 +1,5 @@
# Dialogue: bar-owner at The Last Shift
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
# NPC: Lera Sessik (Tier 2, ANCHOR/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.
@@ -13,7 +13,7 @@ lines:
# GREETINGS — territorial welcome
# ==========================================
- id: the-last-shift_d_100
- id: bar-owner_d_001
text: "Usual?"
role: bar-owner
access: [public, peer, insider]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [lera, greeting, economy-of-words]
- id: the-last-shift_d_101
- id: bar-owner_d_002
text: "Long shift?"
role: bar-owner
access: [public, peer]
@@ -33,7 +33,7 @@ lines:
mood: [content]
tags: [lera, greeting]
- id: the-last-shift_d_102
- id: bar-owner_d_003
text: "Bar's clean, drink's cold. That's the deal."
role: bar-owner
access: [public]
@@ -43,7 +43,7 @@ lines:
mood: [content]
tags: [lera, greeting, house-rules]
- id: the-last-shift_d_103
- id: bar-owner_d_004
text: "Sess'll get you. I've got something in the back."
role: bar-owner
access: [public, peer]
@@ -57,7 +57,7 @@ lines:
# BAR MANAGEMENT — house rules, operations
# ==========================================
- id: the-last-shift_d_104
- id: bar-owner_d_005
text: "Fights go outside. First and last warning."
role: bar-owner
access: [public, peer, insider]
@@ -67,7 +67,7 @@ lines:
mood: [content]
tags: [lera, house-rules, authority]
- id: the-last-shift_d_105
- id: bar-owner_d_006
text: "Tab closes at end of cycle. Not at end of your memory."
role: bar-owner
access: [public, peer]
@@ -77,7 +77,7 @@ lines:
mood: [content]
tags: [lera, tab, house-rules]
- id: the-last-shift_d_106
- id: bar-owner_d_007
text: "Torek's running a tab. That's fine. Don't ask me why."
role: bar-owner
access: [peer]
@@ -91,7 +91,7 @@ lines:
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
- id: bar-owner_d_008
text: "Lattice stays off at the bar. Sign's on the wall."
role: bar-owner
access: [public, peer, insider]
@@ -102,7 +102,7 @@ lines:
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
- id: bar-owner_d_009
text: "Grain spirit's back. Limited run. Don't make it last two nights."
role: bar-owner
access: [public, peer, insider]
@@ -116,7 +116,7 @@ lines:
# PEOPLE — Lera knows everyone
# ==========================================
- id: the-last-shift_d_109
- id: bar-owner_d_010
text: "Kael's in the back. If you're looking."
role: bar-owner
access: [peer, insider]
@@ -129,7 +129,7 @@ lines:
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
- id: bar-owner_d_011
text: "Naia's been in early the last few nights. Doesn't say much."
role: bar-owner
access: [peer]
@@ -142,7 +142,7 @@ lines:
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
- id: bar-owner_d_012
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
role: bar-owner
access: [public, peer]
@@ -155,7 +155,7 @@ lines:
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
- id: bar-owner_d_013
text: "Tev? Good worker. Three years. Minds their own."
role: bar-owner
access: [public, peer]
@@ -172,7 +172,7 @@ lines:
# RING-ADJACENT — Lera's knowledge edge
# ==========================================
- id: the-last-shift_d_113
- id: bar-owner_d_014
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
role: bar-owner
access: [insider]
@@ -186,7 +186,7 @@ lines:
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
- id: bar-owner_d_015
text: "Kael's got my number. He's been a regular long enough that that means something."
role: bar-owner
access: [peer]
@@ -203,7 +203,7 @@ lines:
# DETECTIVE ENCOUNTER — neutral, unwelcoming
# ==========================================
- id: the-last-shift_d_115
- id: bar-owner_d_016
text: "Commission's welcome. Like everyone else. Bar rules apply."
role: bar-owner
access: [authority, public]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [lera, detective-path, neutral-welcome]
- id: the-last-shift_d_116
- id: bar-owner_d_017
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]
@@ -223,8 +223,8 @@ lines:
mood: [content]
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."
- id: bar-owner_d_018
text: "I've run this bar eighteen years without a Commission complaint. I'd like to keep it that way."
role: bar-owner
access: [authority]
trust: surface
@@ -233,7 +233,7 @@ lines:
mood: [suspicious]
tags: [lera, detective-path, authority-assertion]
- id: the-last-shift_d_118
- id: bar-owner_d_019
text: "I see a lot. I don't always remember all of it."
role: bar-owner
access: [authority]
@@ -250,7 +250,7 @@ lines:
# ELEVATED TRUST — what Lera actually says
# ==========================================
- id: the-last-shift_d_119
- id: bar-owner_d_020
text: "Naia's been asking questions I can't answer. Someone should."
role: bar-owner
access: [peer, insider]
@@ -267,7 +267,7 @@ lines:
# SIGN-OFF / CLOSING TIME
# ==========================================
- id: the-last-shift_d_120
- id: bar-owner_d_021
text: "Last call. Finish up."
role: bar-owner
access: [public, peer, insider]
@@ -277,7 +277,7 @@ lines:
mood: [content]
tags: [lera, closing]
- id: the-last-shift_d_121
- id: bar-owner_d_022
text: "Bar's closed. Come back next cycle."
role: bar-owner
access: [public, peer, insider]
@@ -287,7 +287,7 @@ lines:
mood: [content]
tags: [lera, closing]
- id: the-last-shift_d_122
- id: bar-owner_d_023
text: "Good night. Stay out of trouble."
role: bar-owner
access: [public, peer, insider]
@@ -301,7 +301,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_123
- id: bar-owner_d_024
text: "You look tired. Sit."
role: bar-owner
access: [peer, insider]
@@ -311,7 +311,7 @@ lines:
mood: [content]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_124
- id: bar-owner_d_025
text: "Same as yesterday?"
role: bar-owner
access: [peer, insider]
@@ -321,7 +321,7 @@ lines:
mood: [content]
tags: [lera, greeting, generation-pass]
- id: the-last-shift_d_125
- id: bar-owner_d_026
text: "New face. Bar rules are on the wall."
role: bar-owner
access: [public]
@@ -331,7 +331,7 @@ lines:
mood: [content]
tags: [lera, greeting, newcomer, generation-pass]
- id: the-last-shift_d_126
- id: bar-owner_d_027
text: "No trouble tonight. I mean it."
role: bar-owner
access: [public, peer, insider]
@@ -341,7 +341,7 @@ lines:
mood: [content]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_127
- id: bar-owner_d_028
text: "Sess'll get you settled. I'm in the back."
role: bar-owner
access: [public, peer]
@@ -351,7 +351,7 @@ lines:
mood: [content]
tags: [lera, redirect, generation-pass]
- id: the-last-shift_d_128
- id: bar-owner_d_029
text: "We close at 2300. Don't make me say it twice."
role: bar-owner
access: [public, peer]
@@ -361,7 +361,7 @@ lines:
mood: [content]
tags: [lera, house-rules, generation-pass]
- id: the-last-shift_d_129
- id: bar-owner_d_030
text: "Good crowd tonight. Quiet enough."
role: bar-owner
access: [peer, insider]
@@ -371,7 +371,7 @@ lines:
mood: [content]
tags: [lera, ambient, generation-pass]
- id: the-last-shift_d_130
- id: bar-owner_d_031
text: "Bar's yours. I've got accounts."
role: bar-owner
access: [public, peer, insider]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — familiar, community register
# ==========================================
- id: the-last-shift_d_300
- id: bar-regular_d_001
text: "Long one today?"
role: bar-regular
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting]
- id: the-last-shift_d_301
- id: bar-regular_d_002
text: "You're in early. Good shift or bad shift?"
role: bar-regular
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting]
- id: the-last-shift_d_302
- id: bar-regular_d_003
text: "Grain spirit's back. I already had two. Don't judge."
role: bar-regular
access: [public, peer]
@@ -46,7 +46,7 @@ lines:
# TERMINAL FLOOR — off-duty opinions
# ==========================================
- id: the-last-shift_d_303
- id: bar-regular_d_004
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
role: bar-regular
access: [public, peer]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [bar-regular, terminal, voss]
- id: the-last-shift_d_304
- id: bar-regular_d_005
text: "Maret flagged another one. Nobody knows what Voss does with them."
role: bar-regular
access: [peer]
@@ -69,7 +69,7 @@ lines:
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
- id: bar-regular_d_006
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
role: bar-regular
access: [peer]
@@ -82,7 +82,7 @@ lines:
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
- id: bar-regular_d_007
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]
@@ -95,7 +95,7 @@ lines:
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
- id: bar-regular_d_008
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
role: bar-regular
access: [public, peer]
@@ -108,7 +108,7 @@ lines:
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
- id: bar-regular_d_009
text: "Voss has been on the floor more than usual this cycle. Watching everything."
role: bar-regular
access: [peer]
@@ -125,7 +125,7 @@ lines:
# COMMUNITY — the full life of this district
# ==========================================
- id: the-last-shift_d_309
- id: bar-regular_d_010
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
role: bar-regular
access: [public, peer]
@@ -135,7 +135,7 @@ lines:
mood: [content]
tags: [bar-regular, sports, drifters]
- id: the-last-shift_d_310
- id: bar-regular_d_011
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]
@@ -145,49 +145,49 @@ lines:
mood: [content]
tags: [bar-regular, infrastructure, grievance]
- id: the-last-shift_d_311
- id: bar-regular_d_012
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]
topic: [personal, routine]
mood: [content]
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
- id: bar-regular_d_013
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]
topic: [personal, routine]
mood: [content]
tags: [bar-regular, clinic, community]
- id: the-last-shift_d_313
- id: bar-regular_d_014
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]
topic: [institution, personal]
mood: [content]
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
- id: bar-regular_d_015
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]
topic: [institution, personal]
mood: [suspicious]
tags: [bar-regular, workers-association, voss, transition-window]
dual_lens:
@@ -198,7 +198,7 @@ lines:
# DETECTIVE ENCOUNTER — community wariness
# ==========================================
- id: the-last-shift_d_315
- id: bar-regular_d_016
text: "You new? Haven't seen you before."
role: bar-regular
access: [public]
@@ -208,7 +208,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, detective-path, recognition]
- id: the-last-shift_d_316
- id: bar-regular_d_017
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
role: bar-regular
access: [authority]
@@ -218,7 +218,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, detective-path, deflection]
- id: the-last-shift_d_317
- id: bar-regular_d_018
text: "Ask Lera. She knows what happens in this bar better than anyone."
role: bar-regular
access: [authority, public]
@@ -232,7 +232,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_318
- id: bar-regular_d_019
text: "Another cycle done. Same tomorrow."
role: bar-regular
access: [public, peer]
@@ -242,7 +242,7 @@ lines:
mood: [content]
tags: [bar-regular, closing]
- id: the-last-shift_d_319
- id: bar-regular_d_020
text: "Safe home. Watch the corridors after 2200."
role: bar-regular
access: [public, peer]
@@ -256,7 +256,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_320
- id: bar-regular_d_021
text: "Pull up a chair. Floor's full otherwise."
role: bar-regular
access: [public, peer]
@@ -266,7 +266,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_321
- id: bar-regular_d_022
text: "You're in late. Voss kept you?"
role: bar-regular
access: [peer]
@@ -276,7 +276,7 @@ lines:
mood: [content]
tags: [bar-regular, greeting, voss, generation-pass]
- id: the-last-shift_d_322
- id: bar-regular_d_023
text: "Just got off. Bay six all night. My back knows it."
role: bar-regular
access: [public, peer]
@@ -286,7 +286,7 @@ lines:
mood: [frustrated]
tags: [bar-regular, greeting, generation-pass]
- id: the-last-shift_d_323
- id: bar-regular_d_024
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
role: bar-regular
access: [public, peer]
@@ -296,17 +296,17 @@ lines:
mood: [content]
tags: [bar-regular, sports, community, generation-pass]
- id: the-last-shift_d_324
- id: bar-regular_d_025
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]
topic: [institution, personal]
mood: [content]
tags: [bar-regular, infrastructure, grievance, generation-pass]
- id: the-last-shift_d_325
- id: bar-regular_d_026
text: "New hire's asking questions on the floor again. Good. Someone should."
role: bar-regular
access: [peer]
@@ -316,7 +316,7 @@ lines:
mood: [content]
tags: [bar-regular, new-hire, community, generation-pass]
- id: the-last-shift_d_326
- id: bar-regular_d_027
text: "Span gate was loud today. Three cycles now. That's not normal."
role: bar-regular
access: [public, peer]
@@ -326,7 +326,7 @@ lines:
mood: [suspicious]
tags: [bar-regular, infrastructure, generation-pass]
- id: the-last-shift_d_327
- id: bar-regular_d_028
text: "Kael's not in tonight. Unusual."
role: bar-regular
access: [peer]
@@ -339,7 +339,7 @@ lines:
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
- id: bar-regular_d_029
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]
@@ -349,7 +349,7 @@ lines:
mood: [content]
tags: [bar-regular, lera, humor, generation-pass]
- id: the-last-shift_d_329
- id: bar-regular_d_030
text: "Good night. Don't let the Commission ruin your morning."
role: bar-regular
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — service register
# ==========================================
- id: the-last-shift_d_200
- id: bartender_d_001
text: "What'll it be?"
role: bartender
access: [public, peer, insider]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [sess, greeting]
- id: the-last-shift_d_201
- id: bartender_d_002
text: "Back early tonight. Long shift?"
role: bartender
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [sess, greeting, social]
- id: the-last-shift_d_202
- id: bartender_d_003
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
role: bartender
access: [public, peer, insider]
@@ -46,7 +46,7 @@ lines:
# BAR OBSERVATIONS — social intelligence
# ==========================================
- id: the-last-shift_d_203
- id: bartender_d_004
text: "Kael's been in most nights this week. Quiet though. Not his usual."
role: bartender
access: [peer]
@@ -59,7 +59,7 @@ lines:
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
- id: bartender_d_005
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
role: bartender
access: [peer, public]
@@ -72,7 +72,7 @@ lines:
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
- id: bartender_d_006
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
role: bartender
access: [peer]
@@ -85,7 +85,7 @@ lines:
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
- id: bartender_d_007
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
role: bartender
access: [public, peer]
@@ -95,7 +95,7 @@ lines:
mood: [content]
tags: [sess, ren, drifter]
- id: the-last-shift_d_207
- id: bartender_d_008
text: "Tev watches the door. Every shift. Lera likes it that way."
role: bartender
access: [peer]
@@ -108,7 +108,7 @@ lines:
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
- id: bartender_d_009
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
role: bartender
access: [peer]
@@ -125,7 +125,7 @@ lines:
# SOCIAL / COMMUNITY
# ==========================================
- id: the-last-shift_d_209
- id: bartender_d_010
text: "Drifters lost again. Tev took it hard. Drank two too many."
role: bartender
access: [public, peer]
@@ -135,7 +135,7 @@ lines:
mood: [content]
tags: [sess, tev, sports, community]
- id: the-last-shift_d_210
- id: bartender_d_011
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
role: bartender
access: [public, peer]
@@ -145,7 +145,7 @@ lines:
mood: [content]
tags: [sess, workers-association, community]
- id: the-last-shift_d_211
- id: bartender_d_012
text: "Quiet night. Good for everyone."
role: bartender
access: [public, peer, insider]
@@ -159,7 +159,7 @@ lines:
# DETECTIVE ENCOUNTER — helpful, unfocused
# ==========================================
- id: the-last-shift_d_212
- id: bartender_d_013
text: "Commission comes in sometimes. Not usually this early in the cycle."
role: bartender
access: [authority, public]
@@ -169,7 +169,7 @@ lines:
mood: [content]
tags: [sess, detective-path, observation]
- id: the-last-shift_d_213
- id: bartender_d_014
text: "I know most of the regulars. Ask me anything. I probably know something."
role: bartender
access: [authority, public]
@@ -182,7 +182,7 @@ lines:
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
- id: bartender_d_015
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]
@@ -196,7 +196,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-last-shift_d_215
- id: bartender_d_016
text: "Last round's on the board. After that, Lera calls it."
role: bartender
access: [public, peer, insider]
@@ -206,7 +206,7 @@ lines:
mood: [content]
tags: [sess, closing]
- id: the-last-shift_d_216
- id: bartender_d_017
text: "Good night. Safe walk."
role: bartender
access: [public, peer, insider]
@@ -220,7 +220,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-last-shift_d_217
- id: bartender_d_018
text: "The usual? Or trying something different tonight?"
role: bartender
access: [peer, insider]
@@ -230,7 +230,7 @@ lines:
mood: [content]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_218
- id: bartender_d_019
text: "Busy one tonight. Give me a second."
role: bartender
access: [public, peer, insider]
@@ -240,7 +240,7 @@ lines:
mood: [content]
tags: [sess, greeting, generation-pass]
- id: the-last-shift_d_219
- id: bartender_d_020
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
role: bartender
access: [peer]
@@ -250,7 +250,7 @@ lines:
mood: [content]
tags: [sess, greeting, social, generation-pass]
- id: the-last-shift_d_220
- id: bartender_d_021
text: "Lera says the specials board is final. Don't ask me to substitute."
role: bartender
access: [public, peer]
@@ -260,7 +260,7 @@ lines:
mood: [content]
tags: [sess, lera, house-rules, generation-pass]
- id: the-last-shift_d_221
- id: bartender_d_022
text: "Torek's in his usual spot. Corner table. Second drink already."
role: bartender
access: [peer]
@@ -270,7 +270,7 @@ lines:
mood: [content]
tags: [sess, torek, observation, generation-pass]
- id: the-last-shift_d_222
- id: bartender_d_023
text: "Ren's been here since this morning. Still on the same drink."
role: bartender
access: [peer]
@@ -280,7 +280,7 @@ lines:
mood: [content]
tags: [sess, ren, observation, generation-pass]
- id: the-last-shift_d_223
- id: bartender_d_024
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]
@@ -290,7 +290,7 @@ lines:
mood: [content]
tags: [sess, sports, community, generation-pass]
- id: the-last-shift_d_224
- id: bartender_d_025
text: "Slow night for a change. Enjoy it."
role: bartender
access: [public, peer, insider]
@@ -300,7 +300,7 @@ lines:
mood: [content]
tags: [sess, ambient, generation-pass]
- id: the-last-shift_d_225
- id: bartender_d_026
text: "Tab's cleared. Lera'll be pleased."
role: bartender
access: [peer]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: day-worker_d_001 — sounds like intel gathering on night operations
# Actually: asking about after-hours freight work availability
# Ticket: #307 | Sprint: 12
@@ -15,7 +15,7 @@ lines:
# Actually: asking if there's paid after-hours freight work available.
# ==========================================
- id: the-last-shift_d_001
- id: day-worker_d_001
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
role: day-worker
access: [public]
@@ -36,7 +36,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: the-last-shift_d_002
- id: day-worker_d_002
text: "Bay seven any better this cycle? The super was a problem last time."
role: day-worker
access: [public]
@@ -46,7 +46,7 @@ lines:
mood: [content]
tags: [ren, job-seeking]
- id: the-last-shift_d_003
- id: day-worker_d_003
text: "Sova's decent. Station layout makes sense once you learn it."
role: day-worker
access: [public]
@@ -56,7 +56,7 @@ lines:
mood: [content]
tags: [ren, atmospheric]
- id: the-last-shift_d_004
- id: day-worker_d_004
text: "Just passing through. Maybe not. Depends on the work."
role: day-worker
access: [public]
@@ -66,7 +66,7 @@ lines:
mood: [content]
tags: [ren, drifter]
- id: the-last-shift_d_005
- id: day-worker_d_005
text: "Cheap drink here's better than most stations I've stopped at."
role: day-worker
access: [public]
@@ -76,7 +76,7 @@ lines:
mood: [content]
tags: [ren, atmospheric]
- id: the-last-shift_d_006
- id: day-worker_d_006
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
role: day-worker
access: [public]
@@ -12,7 +12,7 @@ lines:
# SOCIAL — off-shift casual
# ========================================
- id: the-last-shift_d_001
- id: kael-davan_d_015
text: "Saved you a seat. Lera's got the spiced rice tonight."
role: dock-worker
access: [insider, peer]
@@ -22,7 +22,7 @@ lines:
topic: [personal, colleague]
tags: [kael, greeting, phase-1]
- id: the-last-shift_d_002
- id: kael-davan_d_016
text: "First round's mine. Don't argue — you covered me last week."
role: dock-worker
access: [insider, peer]
@@ -32,7 +32,7 @@ lines:
topic: [personal]
tags: [kael, social, phase-1]
- id: the-last-shift_d_003
- id: kael-davan_d_017
text: "Voss tried to dock my overtime again. Lera told him off. Wish I'd seen it."
role: dock-worker
access: [insider, peer]
@@ -42,7 +42,7 @@ lines:
topic: [colleague, routine]
tags: [kael, humor, voss, lera]
- id: the-last-shift_d_004
- id: kael-davan_d_018
text: "Torek's holding court at the bar again. How does he afford to drink like that?"
role: dock-worker
access: [insider, peer]
@@ -55,7 +55,7 @@ lines:
fact_id: investigation.torek_spending_pattern
confidence: suspects
- id: the-last-shift_d_005
- id: kael-davan_d_019
text: "Quiet night. I like these ones. No drama, just people winding down."
role: dock-worker
access: [peer]
@@ -65,7 +65,7 @@ lines:
topic: [personal]
tags: [kael, atmospheric]
- id: the-last-shift_d_006
- id: kael-davan_d_020
text: "Lera asked about my overtime schedule. Told her I'm trying to cut back."
role: dock-worker
access: [insider, peer]
@@ -79,7 +79,7 @@ lines:
# NAIA REFERENCES — personal, unprompted
# ========================================
- id: the-last-shift_d_007
- id: kael-davan_d_021
text: "Naia's meeting me here after her shift. Try not to embarrass me."
role: dock-worker
access: [insider, peer]
@@ -89,7 +89,7 @@ lines:
topic: [personal]
tags: [kael, unprompted, naia-reference]
- id: the-last-shift_d_008
- id: kael-davan_d_022
text: "Naia wants to plan a trip. Off-station. Somewhere with actual sky. Sounds expensive."
role: dock-worker
access: [insider, peer]
@@ -99,7 +99,7 @@ lines:
topic: [personal]
tags: [kael, unprompted, naia-reference]
- id: the-last-shift_d_009
- id: kael-davan_d_023
text: "She worries. I tell her it's just dock work. She doesn't buy it anymore."
role: dock-worker
access: [insider]
@@ -113,7 +113,7 @@ lines:
# RING OPS — bar context, trust-gated
# ========================================
- id: the-last-shift_d_010
- id: kael-davan_d_024
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
role: dock-worker
access: [insider]
@@ -123,7 +123,7 @@ lines:
topic: [danger]
tags: [kael, ring-ops, nils]
- id: the-last-shift_d_011
- id: kael-davan_d_025
text: "Keep it light tonight. Torek's listening and he doesn't know when to stop."
role: dock-worker
access: [insider]
@@ -133,7 +133,7 @@ lines:
topic: [danger, colleague]
tags: [kael, ring-ops, torek, caution]
- id: the-last-shift_d_012
- id: kael-davan_d_026
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
role: dock-worker
access: [insider]
@@ -150,7 +150,7 @@ lines:
# POST-CONTRADICTION — at the bar, damaged trust
# ========================================
- id: the-last-shift_d_013
- id: kael-davan_d_027
text: "I saved your seat. ...Force of habit."
role: dock-worker
access: [insider, peer]
@@ -160,7 +160,7 @@ lines:
topic: [personal]
tags: [kael, contaminated-trust, phase-5]
- id: the-last-shift_d_014
- id: kael-davan_d_028
text: "Naia keeps asking why I'm quiet. I told her work stress. Not wrong."
role: dock-worker
access: [insider]
@@ -170,7 +170,7 @@ lines:
topic: [personal]
tags: [kael, contaminated-trust, naia-reference, phase-5]
- id: the-last-shift_d_015
- id: kael-davan_d_029
text: "We're good, right? You and me. We're still good."
role: dock-worker
access: [insider]
@@ -187,7 +187,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
- id: the-last-shift_d_016
- id: kael-davan_d_030
text: "Evening. Friend of someone's, or first time at Lera's?"
role: dock-worker
access: [public]
@@ -199,7 +199,7 @@ lines:
notes: "Layer 2 greeting — first meeting at the bar. Kael is curious about a stranger but not suspicious. Off-shift, looser than at the terminal."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: the-last-shift_d_017
- id: kael-davan_d_031
text: "You made it. I was starting to think something came up."
role: dock-worker
access: [peer, insider]
@@ -211,7 +211,7 @@ lines:
notes: "Layer 2 greeting — established, bar context. Warm, they were expected. Kael was watching for them."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: the-last-shift_d_018
- id: kael-davan_d_032
text: "Still in the district. Thought you'd be done by now."
role: dock-worker
access: [authority]
@@ -223,7 +223,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure, bar context. Politely questioning their continued presence. The bar is supposed to be off-limits from work."
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
- id: the-last-shift_d_019
- id: kael-davan_d_033
text: "You came back."
role: dock-worker
access: [peer, insider]
@@ -9,7 +9,7 @@ location: the-last-shift
role: commission-liaison
lines:
- id: the-last-shift_d_204
- id: pc-detective_d_001
text: "Good evening. Off the clock, more or less. Enjoying the local hospitality."
role: commission-liaison
access: [public]
@@ -19,7 +19,7 @@ lines:
topic: [personal]
tags: [pc-detective, greeting, public-tier]
- id: the-last-shift_d_205
- id: pc-detective_d_002
text: "Ms. Venn recommended the ale. She was right. This district has its charms."
role: commission-liaison
access: [public]
@@ -28,7 +28,7 @@ lines:
topic: [routine, colleague]
tags: [pc-detective, social, public-tier, sera]
- id: the-last-shift_d_206
- id: pc-detective_d_003
text: "Just unwinding. The review can wait until morning. Have a good evening."
role: commission-liaison
access: [public]
@@ -9,7 +9,7 @@ location: the-last-shift
role: dock-worker
lines:
- id: the-last-shift_d_201
- id: pc-smuggler_d_001
text: "Evening. Just having a drink with friends. Can I help you?"
role: dock-worker
access: [public]
@@ -19,7 +19,7 @@ lines:
topic: [personal]
tags: [pc-smuggler, greeting, public-tier]
- id: the-last-shift_d_202
- id: pc-smuggler_d_002
text: "Lera's ale is the one good thing about a ten-hour shift. Don't let anyone tell you different."
role: dock-worker
access: [public]
@@ -29,7 +29,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, social, public-tier]
- id: the-last-shift_d_203
- id: pc-smuggler_d_003
text: "Off the clock, thanks. Whatever it is, it can wait until morning."
role: dock-worker
access: [authority]
@@ -38,7 +38,7 @@ lines:
# Phase 3-5: same warmth, but the detective is now watching
# ============================================================
- id: the-last-shift_d_101
- id: sera-venn_d_001
text: "There you are. Grab a seat — Lera's doing that thing with the grain spirit again. Don't ask, just trust me and order the ale."
role: bar-regular
access: [peer]
@@ -48,7 +48,7 @@ lines:
mood: [warm, content]
tags: [greeting, phase-1, establishes-warmth]
- id: the-last-shift_d_102
- id: sera-venn_d_002
text: "Corner booth's free. I saved it — force of habit."
role: bar-regular
access: [peer]
@@ -58,7 +58,7 @@ lines:
mood: [warm]
tags: [greeting, phase-1, spatial-anchor]
- id: the-last-shift_d_103
- id: sera-venn_d_003
text: "You look like you've been reading manifests all day. Sit. I'll get you something that isn't recycled air."
role: bar-regular
access: [peer]
@@ -68,7 +68,7 @@ lines:
mood: [warm, content]
tags: [greeting, phase-1-2]
- id: the-last-shift_d_104
- id: sera-venn_d_004
text: "Evening. Same booth, same drink?"
role: bar-regular
access: [peer]
@@ -78,7 +78,7 @@ lines:
mood: [content]
tags: [greeting, phase-2-5, repeatable]
- id: the-last-shift_d_105
- id: sera-venn_d_005
text: "Good timing. Naia was here earlier — you just missed her."
role: bar-regular
access: [peer]
@@ -88,7 +88,7 @@ lines:
mood: [content]
tags: [greeting, phase-2, naia-reference]
- id: the-last-shift_d_106
- id: sera-venn_d_006
text: "Grab a seat. Quiet night so far — Lera's in a good mood, which means the ale's fresh."
role: bar-regular
access: [peer, public]
@@ -98,7 +98,7 @@ lines:
mood: [content]
tags: [greeting, phase-1-3]
- id: the-last-shift_d_107
- id: sera-venn_d_007
text: "Hey. Long day?"
role: bar-regular
access: [peer]
@@ -108,7 +108,7 @@ lines:
mood: [frustrated]
tags: [greeting, phase-3-5, post-pattern, shorter]
- id: the-last-shift_d_108
- id: sera-venn_d_008
text: "Same time, same place. You're becoming a regular."
role: bar-regular
access: [peer, public]
@@ -123,7 +123,7 @@ lines:
# What Sera says during normal bar evenings
# ============================================================
- id: the-last-shift_d_110
- id: sera-venn_d_009
text: "The Last Shift. Official name. Everyone calls it Lera's, though. Eighteen years behind that counter."
role: bar-regular
access: [peer]
@@ -133,7 +133,7 @@ lines:
mood: [content]
tags: [orientation, district-background]
- id: the-last-shift_d_111
- id: sera-venn_d_010
text: "See the card game in the corner? That's Harek's table. Security officer, off-duty. Harmless — just loud."
role: bar-regular
access: [peer]
@@ -143,7 +143,7 @@ lines:
mood: [content]
tags: [orientation, npc-introduction]
- id: the-last-shift_d_112
- id: sera-venn_d_011
text: "Most of the after-shift crowd comes from the Terminal. Dock workers, scheduler types. They drink hard and leave early."
role: bar-regular
access: [peer]
@@ -153,7 +153,7 @@ lines:
mood: [content]
tags: [orientation, district-background]
- id: the-last-shift_d_113
- id: sera-venn_d_012
text: "Sova's not bad, once you know the rhythms. Shift change is when things move. Between shifts is when you can breathe."
role: bar-regular
access: [peer]
@@ -163,7 +163,7 @@ lines:
mood: [content]
tags: [setting-atmosphere]
- id: the-last-shift_d_114
- id: sera-venn_d_013
text: "I do lattice calibration, mostly. Commission-standard equipment checks. Relay nodes, terminal diagnostics. Exciting stuff."
role: bar-regular
access: [peer]
@@ -173,7 +173,7 @@ lines:
mood: [content]
tags: [self-introduction, institutional-background]
- id: the-last-shift_d_115
- id: sera-venn_d_014
text: "I transferred here fourteen months ago. Border-system relay posting before this. Sova was supposed to be the quiet one."
role: bar-regular
access: [peer]
@@ -183,7 +183,7 @@ lines:
mood: [content]
tags: [backstory, phase-1]
- id: the-last-shift_d_116
- id: sera-venn_d_015
text: "Lera remembers every regular's order. Eighteen years of that. Don't ask how she does it — she'll just pour you one and prove it."
role: bar-regular
access: [peer, public]
@@ -193,7 +193,7 @@ lines:
mood: [warm]
tags: [community-texture]
- id: the-last-shift_d_117
- id: sera-venn_d_016
text: "The shift crowd clears out by 19:00. After that it's just us — regulars, a few stragglers. The good hours."
role: bar-regular
access: [peer]
@@ -203,7 +203,7 @@ lines:
mood: [content]
tags: [setting-atmosphere, routine]
- id: the-last-shift_d_118
- id: sera-venn_d_017
text: "You adapting to station gravity yet? Point-nine-three standard takes a week to stop noticing."
role: bar-regular
access: [peer]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [setting-detail, phase-1]
- id: the-last-shift_d_119
- id: sera-venn_d_018
text: "I like this booth. Corner. You can see the door and the bar. Force of institutional habit, I suppose."
role: bar-regular
access: [peer]
@@ -232,7 +232,7 @@ lines:
# --- Surface trust ---
- id: the-last-shift_d_120
- id: sera-venn_d_019
text: "The Commission runs maintenance checks quarterly. I handle the field side — equipment calibration, relay diagnostics. Standard."
role: bar-regular
access: [peer, authority]
@@ -242,7 +242,7 @@ lines:
mood: [content]
tags: [institutional-background]
- id: the-last-shift_d_121
- id: sera-venn_d_020
text: "Naia teaches the station kids. Primary education, mostly. She's good at it — patient. The kids adore her."
role: bar-regular
access: [peer]
@@ -252,7 +252,7 @@ lines:
mood: [warm]
tags: [naia-reference, phase-2]
- id: the-last-shift_d_122
- id: sera-venn_d_021
text: "The district runs on freight. Terminal handles most of it — container processing, manifest filing. Bread and butter."
role: bar-regular
access: [peer, public]
@@ -262,7 +262,7 @@ lines:
mood: [content]
tags: [district-context]
- id: the-last-shift_d_123
- id: sera-venn_d_022
text: "I don't know the dock workers well. I'm Commission, they're logistics. Different worlds, same station."
role: bar-regular
access: [peer, authority]
@@ -272,7 +272,7 @@ lines:
mood: [content]
tags: [deflection-surface, distance-from-hub]
- id: the-last-shift_d_124
- id: sera-venn_d_023
text: "Most people here are decent. Working shifts, paying bills, coming to Lera's. It's a community."
role: bar-regular
access: [peer, public]
@@ -282,7 +282,7 @@ lines:
mood: [content]
tags: [community-framing]
- id: the-last-shift_d_125
- id: sera-venn_d_024
text: "I can look things up if you need. Commission terminals access compliance records, shift logs, equipment histories. Part of the job."
role: bar-regular
access: [peer, authority]
@@ -294,7 +294,7 @@ lines:
# --- Real trust ---
- id: the-last-shift_d_130
- id: sera-venn_d_025
text: "Naia's been worried lately. About Kael — her partner. He's a dock worker at the Terminal. Late nights, evasive answers. She confided in me."
role: bar-regular
access: [peer]
@@ -307,7 +307,7 @@ lines:
fact_id: "relationship.kael_naia_connection"
confidence: "knows_of"
- id: the-last-shift_d_131
- id: sera-venn_d_026
text: "The Commission kiosk on Level 3 logs every compliance check. If someone ran an unauthorized scan, it would show up there. Hypothetically."
role: bar-regular
access: [peer, authority]
@@ -317,7 +317,7 @@ lines:
mood: [focused]
tags: [institutional-access, phase-2-3, self-incriminating-hint]
- id: the-last-shift_d_132
- id: sera-venn_d_027
text: "I worry about Naia. She doesn't deserve whatever's happening. She just wants Kael to come home at a reasonable hour."
role: bar-regular
access: [peer]
@@ -327,7 +327,7 @@ lines:
mood: [frustrated, anxious]
tags: [motivation-reveal, phase-2-3]
- id: the-last-shift_d_133
- id: sera-venn_d_028
text: "Compliance data goes back eighteen months on the Commission systems. Anyone with field-tech access could cross-reference shift records with manifest filings."
role: bar-regular
access: [peer, authority]
@@ -340,7 +340,7 @@ lines:
fact_id: "investigation.manifest_discrepancy"
confidence: "suspects"
- id: the-last-shift_d_134
- id: sera-venn_d_029
text: "I'm not an investigator. I calibrate equipment and file reports. But I notice things. Patterns in the data, mostly."
role: bar-regular
access: [peer]
@@ -350,7 +350,7 @@ lines:
mood: [focused]
tags: [self-awareness, capability-reveal]
- id: the-last-shift_d_135
- id: sera-venn_d_030
text: "You know me. I wouldn't sit on something if I thought it mattered. But not everything that looks irregular IS irregular."
role: bar-regular
access: [peer]
@@ -360,7 +360,7 @@ lines:
mood: [anxious]
tags: [deflection-real-tier, phase-4, the-lie]
- id: the-last-shift_d_136
- id: sera-venn_d_031
text: "Some of the regulars here have hard lives. Grey economy, tight margins. I'm not judging. I'm just a field tech who likes the ale."
role: bar-regular
access: [peer]
@@ -372,7 +372,7 @@ lines:
# --- Secret trust ---
- id: the-last-shift_d_140
- id: sera-venn_d_032
text: "I ran a compliance check. Unauthorized. Six weeks ago. Naia was so worried about Kael — I thought I could help. I found manifest discrepancies tied to his shifts."
role: bar-regular
access: [peer]
@@ -385,7 +385,7 @@ lines:
fact_id: "investigation.manifest_discrepancy"
confidence: "knows_details"
- id: the-last-shift_d_141
- id: sera-venn_d_033
text: "If I report it, Naia's life falls apart. Kael gets investigated. Maybe arrested. She didn't ask me to find this. I did it on my own."
role: bar-regular
access: [peer]
@@ -395,7 +395,7 @@ lines:
mood: [anxious]
tags: [motivation-confession, phase-5]
- id: the-last-shift_d_142
- id: sera-venn_d_034
text: "The irregularities correlate with Torek Lintar's inspection shifts. That's why I can't — I can't be near him. He doesn't know I know."
role: bar-regular
access: [peer]
@@ -408,7 +408,7 @@ lines:
fact_id: "investigation.shift_mismatch"
confidence: "knows_details"
- id: the-last-shift_d_143
- id: sera-venn_d_035
text: "I don't know what you're looking for. I told you everything I know. If you're asking whether I'm hiding something — I'm a field tech, not an analyst."
role: bar-regular
access: [peer, authority]
@@ -418,7 +418,7 @@ lines:
mood: [suspicious, anxious]
tags: [denial-under-pressure, phase-4-5, the-wall]
- id: the-last-shift_d_144
- id: sera-venn_d_036
text: "Every tip I gave you was real. The district background, the shift patterns, the regulars. I didn't steer you wrong. I just... didn't give you everything."
role: bar-regular
access: [peer]
@@ -434,7 +434,7 @@ lines:
# Phase 1-2: genuinely helpful. Phase 3-5: overcompensation.
# ============================================================
- id: the-last-shift_d_150
- id: sera-venn_d_037
text: "Oh — before I forget. The dock scheduler, Maret? She's sharp. If you need freight records context, she's the one to ask."
role: bar-regular
access: [peer]
@@ -447,7 +447,7 @@ lines:
fact_id: "relationship.trust_network"
confidence: "suspects"
- id: the-last-shift_d_151
- id: sera-venn_d_038
text: "I checked the relay calibration logs for the Terminal this morning. Nothing flagged. But the data throughput was higher than standard — more manifests processed than usual."
role: bar-regular
access: [peer, authority]
@@ -457,7 +457,7 @@ lines:
mood: [focused]
tags: [unprompted, overcompensation, phase-3-4]
- id: the-last-shift_d_152
- id: sera-venn_d_039
text: "I asked around — quietly — about the shift rotation schedule. Voss has been adjusting it more than usual this cycle. Might mean nothing."
role: bar-regular
access: [peer]
@@ -467,7 +467,7 @@ lines:
mood: [focused, frustrated]
tags: [unprompted, overcompensation, phase-3-4]
- id: the-last-shift_d_153
- id: sera-venn_d_040
text: "The maintenance corridor access logs are Commission-maintained. I can pull them if you need. Just say the word."
role: bar-regular
access: [peer, authority]
@@ -477,7 +477,7 @@ lines:
mood: [content]
tags: [unprompted, information-offer, overcompensation, phase-3-5]
- id: the-last-shift_d_154
- id: sera-venn_d_041
text: "Naia mentioned Kael came home late again last night. Third time this week. She's trying not to make it a thing."
role: bar-regular
access: [peer]
@@ -490,7 +490,7 @@ lines:
fact_id: "relationship.kael_naia_connection"
confidence: "knows_details"
- id: the-last-shift_d_155
- id: sera-venn_d_042
text: "You should know — Lera watches out for her regulars. If people have been asking about you, she'd know. She just won't volunteer it."
role: bar-regular
access: [peer]
@@ -505,7 +505,7 @@ lines:
# Phase 3-4: avoidance excuses, topic deflection, controlled evasion
# ============================================================
- id: the-last-shift_d_160
- id: sera-venn_d_043
text: "Actually, I should head out. Early shift tomorrow — calibration rounds start at 07:30."
role: bar-regular
access: [peer, public]
@@ -515,7 +515,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-1, torek-trigger, phase-3]
- id: the-last-shift_d_161
- id: sera-venn_d_044
text: "Sorry — headache coming on. Station air does that sometimes. I'll catch you tomorrow."
role: bar-regular
access: [peer, public]
@@ -525,7 +525,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-2, torek-trigger, phase-3]
- id: the-last-shift_d_162
- id: sera-venn_d_045
text: "I need to check something at the Commission kiosk. Forgot to file a diagnostic report. I'll be quick — or not. You know how the system is."
role: bar-regular
access: [peer]
@@ -535,7 +535,7 @@ lines:
mood: [content]
tags: [avoidance-excuse-3, torek-trigger, phase-3]
- id: the-last-shift_d_163
- id: sera-venn_d_046
text: "Torek? He's just loud. I don't love loud drunks. Nothing personal — I just like a quieter evening."
role: bar-regular
access: [peer]
@@ -545,7 +545,7 @@ lines:
mood: [content]
tags: [torek-deflection, phase-4, the-deflection]
- id: the-last-shift_d_164
- id: sera-venn_d_047
text: "Manifests? I wouldn't know — I'm on the equipment side, not the freight side. Different department entirely."
role: bar-regular
access: [peer, authority]
@@ -555,7 +555,7 @@ lines:
mood: [content]
tags: [topic-deflection, cargo-avoidance, phase-3-5]
- id: the-last-shift_d_165
- id: sera-venn_d_048
text: "Anyway — did you try the ale? Lera got a new supply run in. Much better than last week's batch."
role: bar-regular
access: [peer]
@@ -565,7 +565,7 @@ lines:
mood: [content]
tags: [redirect, warmth-recovery, phase-4]
- id: the-last-shift_d_166
- id: sera-venn_d_049
text: "I'm just a field tech. I maintain equipment. I don't track people or shipments — that's your department."
role: bar-regular
access: [peer, authority]
@@ -575,7 +575,7 @@ lines:
mood: [suspicious]
tags: [deflection-firm, phase-4-5, the-wall]
- id: the-last-shift_d_167
- id: sera-venn_d_050
text: "Let's talk about something else. How's the investigation going — generally? Making progress?"
role: bar-regular
access: [peer]
@@ -591,7 +591,7 @@ lines:
# Same surface warmth, but calibrated. Trust is contaminated.
# ============================================================
- id: the-last-shift_d_170
- id: sera-venn_d_051
text: "Same booth. Drink's on me tonight."
role: bar-regular
access: [peer]
@@ -601,7 +601,7 @@ lines:
mood: [warm]
tags: [post-discovery, surface-warmth, phase-5]
- id: the-last-shift_d_171
- id: sera-venn_d_052
text: "I pulled the relay diagnostic data you asked about. Clean. No anomalies in the terminal equipment this quarter."
role: bar-regular
access: [peer, authority]
@@ -611,7 +611,7 @@ lines:
mood: [content]
tags: [post-discovery, overcompensation, phase-5]
- id: the-last-shift_d_172
- id: sera-venn_d_053
text: "You've been quiet tonight. Everything all right with the case?"
role: bar-regular
access: [peer]
@@ -621,7 +621,7 @@ lines:
mood: [frustrated]
tags: [post-discovery, awareness, phase-5]
- id: the-last-shift_d_173
- id: sera-venn_d_054
text: "I know you're good at what you do. That's why I'm glad you're here. This district needed someone thorough."
role: bar-regular
access: [peer]
@@ -631,7 +631,7 @@ lines:
mood: [warm, anxious]
tags: [post-discovery, genuine-but-contaminated, phase-5]
- id: the-last-shift_d_174
- id: sera-venn_d_055
text: "If there's anything I can help with — anything at all — you just ask. You know that."
role: bar-regular
access: [peer]
@@ -650,7 +650,7 @@ lines:
# first_meeting (detective path): RelationshipState Unknown → Public/Authority access.
# Sera clocks them as Commission immediately. Makes them welcome anyway.
- id: the-last-shift_d_175
- id: sera-venn_d_056
text: "Commission? Don't worry, I don't bite. I'm Sera. Corner booth's better — you can see the whole room."
role: bar-regular
access: [public, authority]
@@ -663,7 +663,7 @@ lines:
# established (detective path): count >= 3, RelationshipState: Known/Friendly → Peer access.
# Warmer, more personal. Sera has been waiting for them.
- id: the-last-shift_d_176
- id: sera-venn_d_057
text: "I was hoping you'd come in. Saved the corner booth. How'd today go?"
role: bar-regular
access: [peer]
@@ -676,7 +676,7 @@ lines:
# first_meeting (smuggler path): smuggler is dock crew, Sera knows the district crowd.
# Peer/insider access from the start — Sera has social network, not institutional distance.
- id: the-last-shift_d_177
- id: sera-venn_d_058
text: "Terminal crew? Grab a seat. Kael usually sits in the back if you're looking for him."
role: bar-regular
access: [peer, insider]
@@ -689,7 +689,7 @@ lines:
# post-confrontation: confrontation logged. Sera's surface warmth is present but qualified.
# She doesn't know where they stand. The welcome is real but uncertain.
- id: the-last-shift_d_178
- id: sera-venn_d_059
text: "You're back. I wasn't sure you would be."
role: bar-regular
access: [peer]
@@ -13,7 +13,7 @@ lines:
# ARRIVALS — checking in
# ==========================================
- id: the-terminal_d_500
- id: courier_d_001
text: "Route's clear. Moving on schedule."
role: courier
access: [public, peer]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [renn, greeting, operational]
- id: the-terminal_d_501
- id: courier_d_002
text: "Manifest for bay three. Needs a scheduler stamp."
role: courier
access: [public, peer]
@@ -33,7 +33,7 @@ lines:
mood: [content]
tags: [renn, greeting, procedural]
- id: the-terminal_d_502
- id: courier_d_003
text: "Inbound from Havara. Three cans, all matched."
role: courier
access: [public, peer]
@@ -47,7 +47,7 @@ lines:
# SURFACE ROUTINE — low-information, on-script
# ==========================================
- id: the-terminal_d_503
- id: courier_d_004
text: "Not staying. Just the drop."
role: courier
access: [public, peer]
@@ -57,7 +57,7 @@ lines:
mood: [content]
tags: [renn, operational, brief]
- id: the-terminal_d_504
- id: courier_d_005
text: "Paperwork's clean. You can check."
role: courier
access: [public, peer, authority]
@@ -67,7 +67,7 @@ lines:
mood: [content]
tags: [renn, procedural, documentation]
- id: the-terminal_d_505
- id: courier_d_006
text: "Pickup's scheduled at 1600. I'll be back."
role: courier
access: [public, peer]
@@ -77,7 +77,7 @@ lines:
mood: [content]
tags: [renn, operational]
- id: the-terminal_d_506
- id: courier_d_007
text: "Corridor's clear from my end. No delays."
role: courier
access: [public, peer]
@@ -94,7 +94,7 @@ lines:
# RING-ADJACENT — insider/real trust only
# ==========================================
- id: the-terminal_d_507
- id: courier_d_008
text: "Documentation's handled. Bay four, per the note."
role: courier
access: [insider]
@@ -107,7 +107,7 @@ lines:
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
detective: "Never hears this line."
- id: the-terminal_d_508
- id: courier_d_009
text: "Transit stamp's current. Nothing flagged at the gate."
role: courier
access: [insider]
@@ -117,7 +117,7 @@ lines:
mood: [focused]
tags: [renn, ring-ops, documentation]
- id: the-terminal_d_509
- id: courier_d_010
text: "Pickup window is the same as last cycle. Twenty minutes."
role: courier
access: [insider]
@@ -130,7 +130,7 @@ lines:
smuggler: "Twenty minutes. Transition window confirmation."
detective: "Never hears this line."
- id: the-terminal_d_510
- id: courier_d_011
text: "Tell Voss the hold note clears at 1400."
role: courier
access: [insider]
@@ -147,7 +147,7 @@ lines:
# DETECTIVE ENCOUNTER — surface cooperation
# ==========================================
- id: the-terminal_d_511
- id: courier_d_012
text: "Licensed freight courier. Manifest's available on request."
role: courier
access: [authority]
@@ -157,7 +157,7 @@ lines:
mood: [content]
tags: [renn, detective-path, procedural]
- id: the-terminal_d_512
- id: courier_d_013
text: "I drop documentation. What's inside the containers isn't my business."
role: courier
access: [authority]
@@ -170,7 +170,7 @@ lines:
smuggler: "That's the answer. Clean and correct."
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
- id: the-terminal_d_513
- id: courier_d_014
text: "I've been running this route for two years. Nothing unusual."
role: courier
access: [authority, public]
@@ -184,7 +184,7 @@ lines:
# DEPARTURES
# ==========================================
- id: the-terminal_d_514
- id: courier_d_015
text: "That's the drop. I'm out."
role: courier
access: [public, peer]
@@ -194,7 +194,7 @@ lines:
mood: [content]
tags: [renn, closing]
- id: the-terminal_d_515
- id: courier_d_016
text: "Next run's tomorrow. Early."
role: courier
access: [public, peer]
@@ -208,7 +208,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_516
- id: courier_d_017
text: "Two stops today. First one's done."
role: courier
access: [public, peer]
@@ -218,7 +218,7 @@ lines:
mood: [content]
tags: [renn, greeting, generation-pass]
- id: the-terminal_d_517
- id: courier_d_018
text: "Outbound for Havara. Needs a bay assignment."
role: courier
access: [public, peer]
@@ -228,7 +228,7 @@ lines:
mood: [content]
tags: [renn, greeting, procedural, generation-pass]
- id: the-terminal_d_518
- id: courier_d_019
text: "Gate's backed up. Running twenty minutes late."
role: courier
access: [public, peer]
@@ -238,7 +238,7 @@ lines:
mood: [content]
tags: [renn, delay, generation-pass]
- id: the-terminal_d_519
- id: courier_d_020
text: "Paperwork's in order. Same as always."
role: courier
access: [public, peer, authority]
@@ -248,7 +248,7 @@ lines:
mood: [content]
tags: [renn, procedural, generation-pass]
- id: the-terminal_d_520
- id: courier_d_021
text: "Quick turnaround today. Back out by 1300."
role: courier
access: [public, peer]
@@ -258,7 +258,7 @@ lines:
mood: [content]
tags: [renn, operational, generation-pass]
- id: the-terminal_d_521
- id: courier_d_022
text: "Signed off on bay three. That's me done here."
role: courier
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_200
- id: dock-worker_d_001
text: "Push today. Bay six's backed up."
role: dock-worker
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [dock-worker, greeting]
- id: the-terminal_d_201
- id: dock-worker_d_002
text: "Late night last cycle. Bay eight ran until 2300."
role: dock-worker
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [frustrated]
tags: [dock-worker, greeting]
- id: the-terminal_d_202
- id: dock-worker_d_003
text: "Voss is on already. Floor's locked down tight."
role: dock-worker
access: [peer]
@@ -49,7 +49,7 @@ lines:
# FLOOR TALK — routine, cargo, observation
# ==========================================
- id: the-terminal_d_203
- id: dock-worker_d_004
text: "Bay four's been on hold three days. Nobody says why."
role: dock-worker
access: [public, peer]
@@ -62,7 +62,7 @@ lines:
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
- id: dock-worker_d_005
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
role: dock-worker
access: [public, peer]
@@ -72,7 +72,7 @@ lines:
mood: [content]
tags: [dock-worker, manifest]
- id: the-terminal_d_205
- id: dock-worker_d_006
text: "Maret's been at that terminal all morning. Never good."
role: dock-worker
access: [peer]
@@ -85,7 +85,7 @@ lines:
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
- id: dock-worker_d_007
text: "Drin ran bay six again last cycle. Third inspection this week."
role: dock-worker
access: [public, peer]
@@ -98,7 +98,7 @@ lines:
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
- id: dock-worker_d_008
text: "Transition window's twice as long with Commission eyes on the floor."
role: dock-worker
access: [peer]
@@ -111,7 +111,7 @@ lines:
smuggler: "Commission presence during transition. Window's compromised."
detective: "Workers slow down when Commission is visible. Normal — or trained."
- id: the-terminal_d_208
- id: dock-worker_d_009
text: "Good crew this cycle. Linn and Pael are both on."
role: dock-worker
access: [public, peer]
@@ -121,7 +121,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, community]
- id: the-terminal_d_209
- id: dock-worker_d_010
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
role: dock-worker
access: [peer]
@@ -137,7 +137,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_210
- id: dock-worker_d_011
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
role: dock-worker
access: [peer]
@@ -154,7 +154,7 @@ lines:
# COMMUNITY / CASUAL
# ==========================================
- id: the-terminal_d_211
- id: dock-worker_d_012
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
role: dock-worker
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
mood: [content]
tags: [dock-worker, social, the-last-shift]
- id: the-terminal_d_212
- id: dock-worker_d_013
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
role: dock-worker
access: [public, peer]
@@ -174,7 +174,7 @@ lines:
mood: [content]
tags: [dock-worker, workers-association, community]
- id: the-terminal_d_213
- id: dock-worker_d_014
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
role: dock-worker
access: [public, peer]
@@ -188,7 +188,7 @@ lines:
# DETECTIVE ENCOUNTER — deflection, redirect
# ==========================================
- id: the-terminal_d_214
- id: dock-worker_d_015
text: "Manifest questions go to the scheduler. I'm floor-side."
role: dock-worker
access: [authority, public]
@@ -198,7 +198,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, detective-path, redirect]
- id: the-terminal_d_215
- id: dock-worker_d_016
text: "Commission's asked before. Same answers. We run a clean floor."
role: dock-worker
access: [authority]
@@ -208,7 +208,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, detective-path, deflection]
- id: the-terminal_d_216
- id: dock-worker_d_017
text: "Voss handles discrepancies. That's above my level."
role: dock-worker
access: [authority, peer]
@@ -222,7 +222,7 @@ lines:
# PEER CANDOR — elevated trust
# ==========================================
- id: the-terminal_d_217
- id: dock-worker_d_018
text: "Maret's going to push it. She doesn't leave a number sitting."
role: dock-worker
access: [peer]
@@ -235,7 +235,7 @@ lines:
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
- id: dock-worker_d_019
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
role: dock-worker
access: [peer]
@@ -252,7 +252,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_219
- id: dock-worker_d_020
text: "Bay's clear. Good shift."
role: dock-worker
access: [public, peer]
@@ -262,7 +262,7 @@ lines:
mood: [content]
tags: [dock-worker, closing]
- id: the-terminal_d_220
- id: dock-worker_d_021
text: "Night crew's here. We're done."
role: dock-worker
access: [public, peer]
@@ -276,7 +276,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_221
- id: dock-worker_d_022
text: "Early start. Bay two's already loaded."
role: dock-worker
access: [public, peer]
@@ -286,7 +286,7 @@ lines:
mood: [content]
tags: [dock-worker, greeting, generation-pass]
- id: the-terminal_d_222
- id: dock-worker_d_023
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
role: dock-worker
access: [public, peer]
@@ -296,7 +296,7 @@ lines:
mood: [content]
tags: [dock-worker, station, generation-pass]
- id: the-terminal_d_223
- id: dock-worker_d_024
text: "Pael's in early. Something must be broken in B-7 again."
role: dock-worker
access: [peer]
@@ -306,7 +306,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, generation-pass]
- id: the-terminal_d_224
- id: dock-worker_d_025
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
role: dock-worker
access: [public, peer]
@@ -316,7 +316,7 @@ lines:
mood: [content]
tags: [dock-worker, social, generation-pass]
- id: the-terminal_d_225
- id: dock-worker_d_026
text: "Bay three's got a scan hold. Not my problem. Not my bay."
role: dock-worker
access: [public, peer]
@@ -326,7 +326,7 @@ lines:
mood: [content]
tags: [dock-worker, scan-hold, generation-pass]
- id: the-terminal_d_226
- id: dock-worker_d_027
text: "New hire's back. Asking questions again. Good kid."
role: dock-worker
access: [peer]
@@ -336,7 +336,7 @@ lines:
mood: [content]
tags: [dock-worker, new-hire, generation-pass]
- id: the-terminal_d_227
- id: dock-worker_d_028
text: "Handoff's in ten. Don't disappear on me."
role: dock-worker
access: [peer]
@@ -346,7 +346,7 @@ lines:
mood: [content]
tags: [dock-worker, handoff, generation-pass]
- id: the-terminal_d_228
- id: dock-worker_d_029
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
role: dock-worker
access: [public, peer]
@@ -356,7 +356,7 @@ lines:
mood: [suspicious]
tags: [dock-worker, span-gate, generation-pass]
- id: the-terminal_d_229
- id: dock-worker_d_030
text: "Linn's not in today. We're running two short on bay six."
role: dock-worker
access: [public, peer]
@@ -366,7 +366,7 @@ lines:
mood: [frustrated]
tags: [dock-worker, staffing, generation-pass]
- id: the-terminal_d_230
- id: dock-worker_d_031
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]
@@ -376,7 +376,7 @@ lines:
mood: [content]
tags: [dock-worker, pael, humor, generation-pass]
- id: the-terminal_d_231
- id: dock-worker_d_032
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
role: dock-worker
access: [public, peer]
@@ -386,7 +386,7 @@ lines:
mood: [content]
tags: [dock-worker, overtime, generation-pass]
- id: the-terminal_d_232
- id: dock-worker_d_033
text: "Off on time today. First time this cycle."
role: dock-worker
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — Phase 1: Comfort (0-10 min)
# ========================================
- id: the-terminal_d_001
- id: kael-davan_d_034
text: "There you are. Good — I was starting to wonder."
role: dock-worker
access: [insider, peer]
@@ -22,7 +22,7 @@ lines:
topic: [colleague]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_002
- id: kael-davan_d_035
text: "Morning. Freight's stacked clean today. Should be a smooth one."
role: dock-worker
access: [insider, peer]
@@ -32,7 +32,7 @@ lines:
topic: [routine]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_003
- id: kael-davan_d_036
text: "Voss has the schedule posted already. Keen today. That's suspicious."
role: dock-worker
access: [insider, peer]
@@ -42,7 +42,7 @@ lines:
topic: [colleague, routine]
tags: [kael, greeting, humor]
- id: the-terminal_d_004
- id: kael-davan_d_037
text: "Hey. Grab a coffee before Voss finds something to complain about."
role: dock-worker
access: [insider, peer]
@@ -52,7 +52,7 @@ lines:
topic: [colleague]
tags: [kael, greeting, phase-1]
- id: the-terminal_d_005
- id: kael-davan_d_038
text: "You look like you slept about as well as I did."
role: dock-worker
access: [peer]
@@ -63,7 +63,7 @@ lines:
tags: [kael, greeting, casual]
# Public greeting for detective / outsiders
- id: the-terminal_d_006
- id: kael-davan_d_039
text: "Can I help you? If you're looking for the supervisor, Voss is by the schedule board."
role: dock-worker
access: [public, authority]
@@ -77,7 +77,7 @@ lines:
# SHIFT TALK — routine dialogue
# ========================================
- id: the-terminal_d_007
- id: kael-davan_d_040
text: "Bay three's running behind. If we don't clear it by shift end, Voss is going to lose it."
role: dock-worker
access: [insider, peer]
@@ -87,7 +87,7 @@ lines:
topic: [routine, cargo]
tags: [kael, operational]
- id: the-terminal_d_008
- id: kael-davan_d_041
text: "Manifest says this one's forty kilos over. Logging error, probably."
role: dock-worker
access: [public, peer]
@@ -100,7 +100,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_009
- id: kael-davan_d_042
text: "Night shift left the dock in decent shape for once. Miracles happen."
role: dock-worker
access: [insider, peer]
@@ -110,7 +110,7 @@ lines:
topic: [routine]
tags: [kael, humor]
- id: the-terminal_d_010
- id: kael-davan_d_043
text: "Loading arm three's been grinding all week. Someone should file a maintenance ticket."
role: dock-worker
access: [public, peer]
@@ -120,7 +120,7 @@ lines:
topic: [routine]
tags: [kael, environmental]
- id: the-terminal_d_011
- id: kael-davan_d_044
text: "Maret rerouted the bay four queue. Tight schedule today."
role: dock-worker
access: [insider, peer]
@@ -130,7 +130,7 @@ lines:
topic: [cargo, colleague]
tags: [kael, operational, maret]
- id: the-terminal_d_012
- id: kael-davan_d_045
text: "Shift transition in twenty. After that it's Drin's problem."
role: dock-worker
access: [insider, peer]
@@ -140,7 +140,7 @@ lines:
topic: [routine]
tags: [kael, operational]
- id: the-terminal_d_013
- id: kael-davan_d_046
text: "Naia's got a school event tonight. I'm heading home straight after shift."
role: dock-worker
access: [insider, peer]
@@ -150,7 +150,7 @@ lines:
topic: [personal]
tags: [kael, naia-reference, casual]
- id: the-terminal_d_014
- id: kael-davan_d_047
text: "New hire started on bay seven. Keen. Reminds me of my first week."
role: dock-worker
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
# RING COORDINATION — insider, real trust
# ========================================
- id: the-terminal_d_015
- id: kael-davan_d_048
text: "Container 4471 is flagged. I'll reroute it during shift transition."
role: dock-worker
access: [insider]
@@ -174,7 +174,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, operational, phase-2]
- id: the-terminal_d_016
- id: kael-davan_d_049
text: "Voss kept the window clear. Twenty minutes, maybe twenty-five."
role: dock-worker
access: [insider]
@@ -184,7 +184,7 @@ lines:
topic: [cargo, routine]
tags: [kael, ring-ops, operational, phase-2]
- id: the-terminal_d_017
- id: kael-davan_d_050
text: "Renn's on the corridor side. Package moves when I give the signal."
role: dock-worker
access: [insider]
@@ -194,7 +194,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, renn, phase-2]
- id: the-terminal_d_018
- id: kael-davan_d_051
text: "Manifest's clean on my end. Maret processed it without questions."
role: dock-worker
access: [insider]
@@ -204,7 +204,7 @@ lines:
topic: [cargo]
tags: [kael, ring-ops, maret, phase-2]
- id: the-terminal_d_019
- id: kael-davan_d_052
text: "Nils wants higher volume next cycle. I told him we're already tight."
role: dock-worker
access: [insider]
@@ -214,7 +214,7 @@ lines:
topic: [danger, trust]
tags: [kael, ring-ops, nils, tension, phase-2]
- id: the-terminal_d_020
- id: kael-davan_d_053
text: "Drin waved through bay four without checking. At least he's reliable."
role: dock-worker
access: [insider]
@@ -227,7 +227,7 @@ lines:
fact_id: investigation.drin_inspection_pattern
confidence: knows_details
- id: the-terminal_d_021
- id: kael-davan_d_054
text: "Keep an eye on Maret. She's been looking at the manifests longer than usual."
role: dock-worker
access: [insider]
@@ -237,7 +237,7 @@ lines:
topic: [colleague, danger]
tags: [kael, ring-ops, maret, caution, phase-2]
- id: the-terminal_d_022
- id: kael-davan_d_055
text: "There's a sealed one in the batch tonight. Nils says components. Don't open it."
role: dock-worker
access: [insider]
@@ -251,7 +251,7 @@ lines:
# TRUST-GATED — deep trust, secret tier
# ========================================
- id: the-terminal_d_023
- id: kael-davan_d_056
text: "Some days I wonder how long we can keep this up."
role: dock-worker
access: [insider]
@@ -261,7 +261,7 @@ lines:
topic: [trust, personal]
tags: [kael, vulnerability, friend-arc]
- id: the-terminal_d_024
- id: kael-davan_d_057
text: "Naia asked me again last night. Where I go. Why I'm late. I hate lying to her."
role: dock-worker
access: [insider]
@@ -271,7 +271,7 @@ lines:
topic: [personal, trust]
tags: [kael, naia-reference, vulnerability]
- id: the-terminal_d_025
- id: kael-davan_d_058
text: "If something happens to me — look out for Naia. Promise me that."
role: dock-worker
access: [insider]
@@ -285,7 +285,7 @@ lines:
# UNPROMPTED — Kael volunteers or warns
# ========================================
- id: the-terminal_d_026
- id: kael-davan_d_059
text: "Heads up — Voss is in a mood. Keep your head down this shift."
role: dock-worker
access: [insider, peer]
@@ -295,7 +295,7 @@ lines:
topic: [colleague, danger]
tags: [kael, unprompted, warning]
- id: the-terminal_d_027
- id: kael-davan_d_060
text: "I'm buying at Lera's after shift. You're coming. No arguments."
role: dock-worker
access: [insider, peer]
@@ -305,7 +305,7 @@ lines:
topic: [personal, colleague]
tags: [kael, unprompted, social]
- id: the-terminal_d_028
- id: kael-davan_d_061
text: "Torek's been running his mouth at the bar. You might want to have a word."
role: dock-worker
access: [insider]
@@ -319,7 +319,7 @@ lines:
# DEFLECTION — Phase 2-4: Post-contradiction
# ========================================
- id: the-terminal_d_029
- id: kael-davan_d_062
text: "It's nothing. Just work stuff."
role: dock-worker
access: [insider, peer]
@@ -329,7 +329,7 @@ lines:
topic: [personal]
tags: [kael, deflection, phase-4]
- id: the-terminal_d_030
- id: kael-davan_d_063
text: "Don't worry about it. I've got it handled."
role: dock-worker
access: [insider, peer]
@@ -339,7 +339,7 @@ lines:
topic: [personal, trust]
tags: [kael, deflection, phase-4]
- id: the-terminal_d_031
- id: kael-davan_d_064
text: "I was checking on a maintenance issue. That's all."
role: dock-worker
access: [insider, peer]
@@ -349,7 +349,7 @@ lines:
topic: [routine]
tags: [kael, deflection, lying, phase-4]
- id: the-terminal_d_032
- id: kael-davan_d_065
text: "You're reading too much into it. Come on — shift's starting."
role: dock-worker
access: [insider, peer]
@@ -359,7 +359,7 @@ lines:
topic: [routine, trust]
tags: [kael, deflection, subject-change, phase-4]
- id: the-terminal_d_033
- id: kael-davan_d_066
text: "Can we not do this here? Voss is watching."
role: dock-worker
access: [insider]
@@ -374,7 +374,7 @@ lines:
# ========================================
# Path A: Gentle confrontation
- id: the-terminal_d_034
- id: kael-davan_d_067
text: "I... yeah. I need to tell you something. But not here."
role: dock-worker
access: [insider]
@@ -385,7 +385,7 @@ lines:
tags: [kael, confession-path, phase-5]
# Path B: Harsh pressure
- id: the-terminal_d_035
- id: kael-davan_d_068
text: "You want to report me? Go ahead. See what Nils does to both of us."
role: dock-worker
access: [insider]
@@ -396,7 +396,7 @@ lines:
tags: [kael, hostile-path, phase-5]
# If smuggler pushes further (gentle)
- id: the-terminal_d_036
- id: kael-davan_d_069
text: "Naia can't keep living like this. Neither can I. I'm looking for a way out."
role: dock-worker
access: [insider]
@@ -407,7 +407,7 @@ lines:
tags: [kael, confession, naia-reference, phase-5]
# If detective confronts (authority path)
- id: the-terminal_d_037
- id: kael-davan_d_070
text: "I don't know what you're talking about. I load containers. That's my job."
role: dock-worker
access: [public, authority]
@@ -416,7 +416,7 @@ lines:
topic: [routine]
tags: [kael, deflection, detective-path]
- id: the-terminal_d_038
- id: kael-davan_d_071
text: "You want to ask questions, talk to Voss. He runs the schedule."
role: dock-worker
access: [public, authority]
@@ -432,7 +432,7 @@ lines:
# ========================================
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
- id: the-terminal_d_050
- id: kael-davan_d_072
text: "This section is dock crew and logistics. If you're Commission or admin, check-in's back that way."
role: dock-worker
access: [public]
@@ -444,7 +444,7 @@ lines:
notes: "Layer 2 greeting — first meeting. Kael is helpful but orienting the stranger away from his work."
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
- id: the-terminal_d_051
- id: kael-davan_d_073
text: "You're back. Good — bay three's been quiet. I'll fill you in."
role: dock-worker
access: [peer, insider]
@@ -456,7 +456,7 @@ lines:
notes: "Layer 2 greeting — established. Warm, practical, skips pleasantries. Treats them as crew."
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
- id: the-terminal_d_052
- id: kael-davan_d_074
text: "Back again. I'm on shift — if you have questions, make them quick."
role: dock-worker
access: [authority]
@@ -468,7 +468,7 @@ lines:
notes: "Layer 2 greeting — repeat with authority figure. Professional, clipped, not hostile. Kael can't refuse but won't volunteer."
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
- id: the-terminal_d_053
- id: kael-davan_d_075
text: "Didn't expect you today."
role: dock-worker
access: [peer, insider]
@@ -1,7 +1,7 @@
# 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
# KEY LINE: maintenance-tech_d_001 — sounds like surveillance knowledge, is a maintenance complaint
# Ticket: #307 | Sprint: 12
location: the-terminal
@@ -14,7 +14,7 @@ lines:
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
# ==========================================
- id: the-terminal_d_040
- id: maintenance-tech_d_001
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
role: maintenance-tech
access: [public]
@@ -35,7 +35,7 @@ lines:
# ROUTINE LINES
# ==========================================
- id: the-terminal_d_041
- id: maintenance-tech_d_002
text: "Air recycler's running hot again. Third deferred ticket this week."
role: maintenance-tech
access: [public]
@@ -45,7 +45,7 @@ lines:
mood: [content]
tags: [pael, atmospheric]
- id: the-terminal_d_042
- id: maintenance-tech_d_003
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
role: maintenance-tech
access: [public]
@@ -55,7 +55,7 @@ lines:
mood: [content]
tags: [pael, atmospheric]
- id: the-terminal_d_043
- id: maintenance-tech_d_004
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
role: maintenance-tech
access: [public]
@@ -65,7 +65,7 @@ lines:
mood: [content]
tags: [pael, warning, helpful]
- id: the-terminal_d_044
- id: maintenance-tech_d_005
text: "Not me. I just fix them."
role: maintenance-tech
access: [public]
@@ -75,7 +75,7 @@ lines:
mood: [content]
tags: [pael, response]
- id: the-terminal_d_045
- id: maintenance-tech_d_006
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
role: maintenance-tech
access: [public]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — early days
# ==========================================
- id: the-terminal_d_400
- id: new-hire_d_001
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
role: new-hire
access: [public, peer]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [new-hire, greeting, orientation]
- id: the-terminal_d_401
- id: new-hire_d_002
text: "Voss told me to check in with you. Is that the standard thing?"
role: new-hire
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [new-hire, greeting, voss]
- id: the-terminal_d_402
- id: new-hire_d_003
text: "First full cycle on the floor. Trying to stay out of the way."
role: new-hire
access: [public, peer]
@@ -46,7 +46,7 @@ lines:
# FRESH OBSERVATIONS — the dangerous innocence
# ==========================================
- id: the-terminal_d_403
- id: new-hire_d_004
text: "Does bay four usually get held that long? The other bays turned over twice already."
role: new-hire
access: [public, peer]
@@ -59,7 +59,7 @@ lines:
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
- id: new-hire_d_005
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
role: new-hire
access: [public, peer]
@@ -76,7 +76,7 @@ lines:
confidence: suspects
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
- id: the-terminal_d_405
- id: new-hire_d_006
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
role: new-hire
access: [public, peer]
@@ -89,7 +89,7 @@ lines:
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
- id: new-hire_d_007
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]
@@ -105,7 +105,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_407
- id: new-hire_d_008
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
role: new-hire
access: [public, peer]
@@ -122,7 +122,7 @@ lines:
# LEARNING / QUESTIONS
# ==========================================
- id: the-terminal_d_408
- id: new-hire_d_009
text: "What's the Commission kiosk actually for? Nobody seems to use it."
role: new-hire
access: [public, peer]
@@ -132,7 +132,7 @@ lines:
mood: [content]
tags: [new-hire, commission, curiosity]
- id: the-terminal_d_409
- id: new-hire_d_010
text: "Maret seems stressed. Is that always the case or is something going on?"
role: new-hire
access: [peer]
@@ -145,7 +145,7 @@ lines:
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
- id: new-hire_d_011
text: "Voss seems really serious. Is he always like that?"
role: new-hire
access: [public, peer]
@@ -155,7 +155,7 @@ lines:
mood: [content]
tags: [new-hire, voss, curiosity]
- id: the-terminal_d_411
- id: new-hire_d_012
text: "How long before Voss trusts you to run a bay on your own?"
role: new-hire
access: [peer]
@@ -169,7 +169,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_412
- id: new-hire_d_013
text: "That was a full shift. I'll have the bay numbers down by next cycle."
role: new-hire
access: [public, peer]
@@ -179,7 +179,7 @@ lines:
mood: [content]
tags: [new-hire, closing]
- id: the-terminal_d_413
- id: new-hire_d_014
text: "Good day. Lot to learn. But good."
role: new-hire
access: [public, peer]
@@ -193,7 +193,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_414
- id: new-hire_d_015
text: "Is there a trick to the manifest terminal or does it always lag like this?"
role: new-hire
access: [public, peer]
@@ -203,7 +203,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_415
- id: new-hire_d_016
text: "How long has Maret been here? She seems like she knows everything."
role: new-hire
access: [peer]
@@ -213,7 +213,7 @@ lines:
mood: [content]
tags: [new-hire, maret, curiosity, generation-pass]
- id: the-terminal_d_416
- id: new-hire_d_017
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
role: new-hire
access: [public, peer]
@@ -223,7 +223,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, generation-pass]
- id: the-terminal_d_417
- id: new-hire_d_018
text: "The Last Shift — is that actually a bar? I haven't been yet."
role: new-hire
access: [public, peer]
@@ -233,7 +233,7 @@ lines:
mood: [content]
tags: [new-hire, social, generation-pass]
- id: the-terminal_d_418
- id: new-hire_d_019
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]
@@ -243,7 +243,7 @@ lines:
mood: [content]
tags: [new-hire, orientation, humor, generation-pass]
- id: the-terminal_d_419
- id: new-hire_d_020
text: "Who's Drin? I keep hearing the name but haven't met them."
role: new-hire
access: [public, peer]
@@ -253,7 +253,7 @@ lines:
mood: [content]
tags: [new-hire, drin, curiosity, generation-pass]
- id: the-terminal_d_420
- id: new-hire_d_021
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
role: new-hire
access: [peer]
@@ -17,7 +17,7 @@ lines:
# Second playthrough: "That's me."
# ========================================
- id: the-terminal_d_044
- id: pc-detective_d_004
text: "Good morning. Commission Liaison Office. I'm conducting a routine compliance review."
role: commission-liaison
access: [public]
@@ -27,7 +27,7 @@ lines:
topic: [routine]
tags: [pc-detective, greeting, public-tier]
- id: the-terminal_d_045
- id: pc-detective_d_005
text: "Standard review. Manifest reconciliation, scheduling compliance. Nothing unusual."
role: commission-liaison
access: [public]
@@ -36,7 +36,7 @@ lines:
topic: [routine]
tags: [pc-detective, purpose-question, public-tier]
- id: the-terminal_d_046
- id: pc-detective_d_006
text: "Only been here a few days. Seems like a well-run operation."
role: commission-liaison
access: [public]
@@ -45,7 +45,7 @@ lines:
topic: [routine]
tags: [pc-detective, district-question, public-tier]
- id: the-terminal_d_047
- id: pc-detective_d_007
text: "Ms. Venn? Colleague. We've worked in the same division."
role: commission-liaison
access: [public]
@@ -54,7 +54,7 @@ lines:
topic: [colleague]
tags: [pc-detective, sera-question, public-tier]
- id: the-terminal_d_048
- id: pc-detective_d_008
text: "I appreciate the interest, but I'm sure you have cargo to route. I won't keep you."
role: commission-liaison
access: [public, authority]
@@ -17,7 +17,7 @@ lines:
# Second playthrough: "That's me."
# ========================================
- id: the-terminal_d_039
- id: pc-smuggler_d_004
text: "Morning. Can I help you with something?"
role: dock-worker
access: [public]
@@ -27,7 +27,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, greeting, public-tier]
- id: the-terminal_d_040
- id: pc-smuggler_d_005
text: "Cargo routing. Containers come in, I process them out. Standard stuff."
role: dock-worker
access: [public, authority]
@@ -36,7 +36,7 @@ lines:
topic: [routine, cargo]
tags: [pc-smuggler, work-question, public-tier]
- id: the-terminal_d_041
- id: pc-smuggler_d_006
text: "It's a freight hub. Busy during shifts, quiet after. Same as anywhere."
role: dock-worker
access: [public]
@@ -45,7 +45,7 @@ lines:
topic: [routine]
tags: [pc-smuggler, hub-question, public-tier]
- id: the-terminal_d_042
- id: pc-smuggler_d_007
text: "Good crew. We do our shifts, go home. Nothing exciting."
role: dock-worker
access: [public, authority]
@@ -54,7 +54,7 @@ lines:
topic: [colleague, routine]
tags: [pc-smuggler, colleagues-question, public-tier]
- id: the-terminal_d_043
- id: pc-smuggler_d_008
text: "Look, I just move containers. You'd want to talk to Voss about scheduling."
role: dock-worker
access: [authority]
@@ -13,7 +13,7 @@ lines:
# GREETINGS — scheduler's opening read
# ==========================================
- id: the-terminal_d_300
- id: scheduler_d_001
text: "Manifest's current. Bay assignments are posted."
role: scheduler
access: [public, peer]
@@ -23,7 +23,7 @@ lines:
mood: [content]
tags: [maret, procedural, greeting]
- id: the-terminal_d_301
- id: scheduler_d_002
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]
@@ -39,7 +39,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: suspects
- id: the-terminal_d_302
- id: scheduler_d_003
text: "Three inbounds today. All verified against the manifest. No gaps so far."
role: scheduler
access: [public, peer]
@@ -53,7 +53,7 @@ lines:
# DISCREPANCY PATTERN — the core arc
# ==========================================
- id: the-terminal_d_303
- id: scheduler_d_004
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
role: scheduler
access: [public, peer]
@@ -69,7 +69,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_304
- id: scheduler_d_005
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
role: scheduler
access: [public, peer]
@@ -82,7 +82,7 @@ lines:
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
- id: scheduler_d_006
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
role: scheduler
access: [peer]
@@ -98,7 +98,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_306
- id: scheduler_d_007
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
role: scheduler
access: [peer]
@@ -111,7 +111,7 @@ lines:
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
- id: scheduler_d_008
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]
@@ -131,7 +131,7 @@ lines:
# ROUTINE SCHEDULING — grounded work
# ==========================================
- id: the-terminal_d_308
- id: scheduler_d_009
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
role: scheduler
access: [public, peer]
@@ -141,7 +141,7 @@ lines:
mood: [content]
tags: [maret, operational]
- id: the-terminal_d_309
- id: scheduler_d_010
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]
@@ -154,7 +154,7 @@ lines:
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
- id: scheduler_d_011
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
role: scheduler
access: [public, peer]
@@ -164,7 +164,7 @@ lines:
mood: [content]
tags: [maret, administrative]
- id: the-terminal_d_311
- id: scheduler_d_012
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
role: scheduler
access: [public, peer]
@@ -178,7 +178,7 @@ lines:
# DETECTIVE ENCOUNTER — access and resistance
# ==========================================
- id: the-terminal_d_312
- id: scheduler_d_013
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
role: scheduler
access: [authority]
@@ -188,7 +188,7 @@ lines:
mood: [content]
tags: [maret, detective-path, procedural]
- id: the-terminal_d_313
- id: scheduler_d_014
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]
@@ -201,7 +201,7 @@ lines:
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
- id: scheduler_d_015
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
role: scheduler
access: [authority]
@@ -218,7 +218,7 @@ lines:
# ELEVATED TRUST — Maret breaks professional distance
# ==========================================
- id: the-terminal_d_315
- id: scheduler_d_016
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
role: scheduler
access: [peer, insider]
@@ -234,7 +234,7 @@ lines:
fact_id: investigation.manifest_discrepancy
confidence: knows_details
- id: the-terminal_d_316
- id: scheduler_d_017
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
role: scheduler
access: [peer]
@@ -250,7 +250,7 @@ lines:
fact_id: behavioral.voss_awareness
confidence: knows_details
- id: the-terminal_d_317
- id: scheduler_d_018
text: "I don't think I should be the one telling you this."
role: scheduler
access: [insider]
@@ -265,7 +265,7 @@ lines:
# SIGN-OFF
# ==========================================
- id: the-terminal_d_318
- id: scheduler_d_019
text: "Manifest posted for the evening shift. I'm off at 1500."
role: scheduler
access: [public, peer]
@@ -275,7 +275,7 @@ lines:
mood: [content]
tags: [maret, closing]
- id: the-terminal_d_319
- id: scheduler_d_020
text: "Numbers don't lie. People lie about numbers."
role: scheduler
access: [peer]
@@ -290,7 +290,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_320
- id: scheduler_d_021
text: "Bay assignments updated as of 0900. Check before you touch anything."
role: scheduler
access: [public, peer]
@@ -300,7 +300,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_321
- id: scheduler_d_022
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
role: scheduler
access: [public, peer]
@@ -310,7 +310,7 @@ lines:
mood: [content]
tags: [maret, operational, generation-pass]
- id: the-terminal_d_322
- id: scheduler_d_023
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
role: scheduler
access: [public, peer]
@@ -320,7 +320,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_323
- id: scheduler_d_024
text: "Overtime requests need scheduler approval now. Voss changed the process."
role: scheduler
access: [public, peer]
@@ -330,7 +330,7 @@ lines:
mood: [content]
tags: [maret, voss, administrative, generation-pass]
- id: the-terminal_d_324
- id: scheduler_d_025
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
role: scheduler
access: [public, peer]
@@ -340,7 +340,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_325
- id: scheduler_d_026
text: "End-of-cycle reconciliation is at 1430. Be available."
role: scheduler
access: [public, peer]
@@ -350,7 +350,7 @@ lines:
mood: [content]
tags: [maret, procedural, generation-pass]
- id: the-terminal_d_326
- id: scheduler_d_027
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
role: scheduler
access: [public, peer]
@@ -360,7 +360,7 @@ lines:
mood: [content]
tags: [maret, procedural, manifest, generation-pass]
- id: the-terminal_d_327
- id: scheduler_d_028
text: "Commission audit prep starts next cycle. Get your documentation current."
role: scheduler
access: [public, peer]
@@ -12,7 +12,7 @@ lines:
# GREETINGS — shift arrival
# ==========================================
- id: the-terminal_d_100
- id: shift-supervisor_d_001
text: "You're late. Don't be late."
role: shift-supervisor
access: [public, peer, insider]
@@ -22,7 +22,7 @@ lines:
mood: [content]
tags: [voss, greeting, authority]
- id: the-terminal_d_101
- id: shift-supervisor_d_002
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
role: shift-supervisor
access: [public, peer]
@@ -32,7 +32,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_102
- id: shift-supervisor_d_003
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
role: shift-supervisor
access: [public, peer]
@@ -45,7 +45,7 @@ lines:
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
- id: shift-supervisor_d_004
text: "Commission liaison's in the district this cycle. Keep the floor clean."
role: shift-supervisor
access: [public, peer]
@@ -62,7 +62,7 @@ lines:
# SHIFT AUTHORITY — routine management
# ==========================================
- id: the-terminal_d_104
- id: shift-supervisor_d_005
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
role: shift-supervisor
access: [public, peer]
@@ -72,7 +72,7 @@ lines:
mood: [content]
tags: [voss, drin, authority]
- id: the-terminal_d_105
- id: shift-supervisor_d_006
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
role: shift-supervisor
access: [public, peer, insider]
@@ -82,7 +82,7 @@ lines:
mood: [content]
tags: [voss, procedural, transition-window]
- id: the-terminal_d_106
- id: shift-supervisor_d_007
text: "Overtime requests go through the Meridian form. Don't bring them to me."
role: shift-supervisor
access: [public, peer]
@@ -92,7 +92,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_107
- id: shift-supervisor_d_008
text: "Bay eight's going to downtime at 1300. Plan around it."
role: shift-supervisor
access: [public, peer]
@@ -102,7 +102,7 @@ lines:
mood: [content]
tags: [voss, operational]
- id: the-terminal_d_108
- id: shift-supervisor_d_009
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
role: shift-supervisor
access: [public, peer]
@@ -118,7 +118,7 @@ lines:
fact_id: investigation.oversight_gap_pattern
confidence: suspects
- id: the-terminal_d_109
- id: shift-supervisor_d_010
text: "Freight's moving. That's what matters. Questions slow things down."
role: shift-supervisor
access: [public, peer]
@@ -132,7 +132,7 @@ lines:
# SCHEDULING COVER — insider, real trust
# ==========================================
- id: the-terminal_d_110
- id: shift-supervisor_d_011
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
role: shift-supervisor
access: [insider]
@@ -145,7 +145,7 @@ lines:
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
- id: shift-supervisor_d_012
text: "Drin's on bay six. Bay four's mine tonight."
role: shift-supervisor
access: [insider]
@@ -155,7 +155,7 @@ lines:
mood: [focused]
tags: [voss, ring-ops, scheduling-cover, drin]
- id: the-terminal_d_112
- id: shift-supervisor_d_013
text: "The hold note is administrative. Don't treat it as anything else."
role: shift-supervisor
access: [insider]
@@ -170,7 +170,7 @@ lines:
# DETECTIVE ENCOUNTER — authority tier
# ==========================================
- id: the-terminal_d_113
- id: shift-supervisor_d_014
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
role: shift-supervisor
access: [authority]
@@ -180,7 +180,7 @@ lines:
mood: [content]
tags: [voss, detective-path, institutional-resistance]
- id: the-terminal_d_114
- id: shift-supervisor_d_015
text: "The manifest's available to any Commission officer. Ask the scheduler."
role: shift-supervisor
access: [authority]
@@ -190,7 +190,7 @@ lines:
mood: [content]
tags: [voss, detective-path, redirect]
- id: the-terminal_d_115
- id: shift-supervisor_d_016
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
role: shift-supervisor
access: [authority]
@@ -200,7 +200,7 @@ lines:
mood: [content]
tags: [voss, detective-path, deflection]
- id: the-terminal_d_116
- id: shift-supervisor_d_017
text: "I've been running this floor for six years. The numbers work out."
role: shift-supervisor
access: [authority, public]
@@ -214,7 +214,7 @@ lines:
# PRESSURE / CONFRONTATION
# ==========================================
- id: the-terminal_d_117
- id: shift-supervisor_d_018
text: "Close the door."
role: shift-supervisor
access: [insider, peer]
@@ -224,7 +224,7 @@ lines:
mood: [frustrated]
tags: [voss, confrontation, private]
- id: the-terminal_d_118
- id: shift-supervisor_d_019
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
role: shift-supervisor
access: [insider]
@@ -237,7 +237,7 @@ lines:
fact_id: behavioral.voss_awareness
confidence: knows_of
- id: the-terminal_d_119
- id: shift-supervisor_d_020
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]
@@ -251,7 +251,7 @@ lines:
# SIGN-OFF / END OF SHIFT
# ==========================================
- id: the-terminal_d_120
- id: shift-supervisor_d_021
text: "Sign-off is at 1400. Be done."
role: shift-supervisor
access: [public, peer]
@@ -261,7 +261,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_121
- id: shift-supervisor_d_022
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
role: shift-supervisor
access: [public, peer]
@@ -271,7 +271,7 @@ lines:
mood: [content]
tags: [voss, procedural]
- id: the-terminal_d_122
- id: shift-supervisor_d_023
text: "Good shift. Tomorrow at 0600."
role: shift-supervisor
access: [public, peer, insider]
@@ -285,7 +285,7 @@ lines:
# GENERATION PASS — ambient variants
# ==========================================
- id: the-terminal_d_123
- id: shift-supervisor_d_024
text: "Assignments don't change once they're posted. Read the board."
role: shift-supervisor
access: [public, peer]
@@ -295,7 +295,7 @@ lines:
mood: [content]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_124
- id: shift-supervisor_d_025
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
role: shift-supervisor
access: [public, peer]
@@ -305,7 +305,7 @@ lines:
mood: [content]
tags: [voss, procedural, maret, generation-pass]
- id: the-terminal_d_125
- id: shift-supervisor_d_026
text: "Span gate's cycling at 1100. Clear the bay six access before then."
role: shift-supervisor
access: [public, peer]
@@ -315,7 +315,7 @@ lines:
mood: [content]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_126
- id: shift-supervisor_d_027
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
role: shift-supervisor
access: [public, peer]
@@ -325,7 +325,7 @@ lines:
mood: [content]
tags: [voss, authority, generation-pass]
- id: the-terminal_d_127
- id: shift-supervisor_d_028
text: "End-of-day log goes to Meridian before you leave. No exceptions."
role: shift-supervisor
access: [public, peer]
@@ -335,7 +335,7 @@ lines:
mood: [content]
tags: [voss, procedural, generation-pass]
- id: the-terminal_d_128
- id: shift-supervisor_d_029
text: "Inbounds from Havara are on the revised schedule. Check the update."
role: shift-supervisor
access: [public, peer]
@@ -345,7 +345,7 @@ lines:
mood: [content]
tags: [voss, operational, generation-pass]
- id: the-terminal_d_129
- id: shift-supervisor_d_030
text: "Clean floor, clean manifest. That's the job."
role: shift-supervisor
access: [public, peer, insider]
@@ -47,8 +47,8 @@ items:
notes: >
Physical proof of cargo routing irregularities. The manifest shows
three containers rerouted during shift transition with weights that
don't reconcile. Cross-ref: Kael dialogue the-terminal_d_008
("Manifest says this one's forty kilos over") and the-terminal_d_018
don't reconcile. Cross-ref: Kael dialogue kael-davan_d_041
("Manifest says this one's forty kilos over") and kael-davan_d_051
("Manifest's clean on my end").
- fact_id: "investigation.cargo_anomaly"
relationship: "corroborates"

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