Compare commits

...
94 Commits
Author SHA1 Message Date
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
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 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
195 changed files with 24676 additions and 1272 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
+86
View File
@@ -6,6 +6,92 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- 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
- 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
+26 -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,73 +46,18 @@ 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 ..."` | — |
| 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)
@@ -149,35 +66,3 @@ Key rules:
- 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

+11
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)
+5
View File
@@ -125,6 +125,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
+44 -10
View File
@@ -70,6 +70,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 +255,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
+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)
@@ -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.
+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
+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}],
}]
+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()
+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:
+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
+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)
+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"
@@ -24,7 +24,7 @@ lines:
# --- Arrival ---
- id: commission-kiosk_m_d_001
- id: pc-detective_m_d_001
text: "Commission terminal. Authorized access, full import logs. Let's see what the system knows."
role: player_character
access: [public]
@@ -33,7 +33,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, tutorial, orientation]
- id: commission-kiosk_m_d_002
- id: pc-detective_m_d_002
text: "Standard regulation spec. Commission requisition 4-C. They didn't upgrade this terminal in three years."
role: player_character
access: [public]
@@ -42,7 +42,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric]
- id: commission-kiosk_m_d_003
- id: pc-detective_m_d_003
text: "Filtered air. A Commission smell — sterile, precise, faintly antiseptic. Familiar."
role: player_character
access: [public]
@@ -51,7 +51,7 @@ lines:
situation: [arrival, routine]
tags: [arrival, atmospheric, sensory]
- id: commission-kiosk_m_d_004
- id: pc-detective_m_d_004
text: "Back at the kiosk. The terminal logged my last access thirty-two minutes ago."
role: player_character
access: [public]
@@ -60,7 +60,7 @@ lines:
situation: [arrival]
tags: [arrival, orientation]
- id: commission-kiosk_m_d_005
- id: pc-detective_m_d_005
text: "Nobody uses this terminal except Commission staff. Which means whoever was here before me has credentials."
role: player_character
access: [public]
@@ -71,7 +71,7 @@ lines:
# --- Perception / Sound ---
- id: commission-kiosk_m_d_006
- id: pc-detective_m_d_006
text: "The terminal hum is different from the freight equipment. Cleaner. Higher frequency."
role: player_character
access: [public]
@@ -80,7 +80,7 @@ lines:
situation: [routine]
tags: [sensory, environmental]
- id: commission-kiosk_m_d_007
- id: pc-detective_m_d_007
text: "Footsteps at the transit corridor entrance. Not Commission — wrong pace."
role: player_character
access: [public]
@@ -89,7 +89,7 @@ lines:
situation: [routine, observation]
tags: [sensory, caution]
- id: commission-kiosk_m_d_008
- id: pc-detective_m_d_008
text: "Someone's running queries at the main manifest board. Loud keystrokes — frustrated."
role: player_character
access: [public]
@@ -100,7 +100,7 @@ lines:
# --- Tutorial / Evidence ---
- id: commission-kiosk_m_d_009
- id: pc-detective_m_d_009
text: "Import logs go back forty days. Weight discrepancies flagged automatically — unless someone cleared the flag."
role: player_character
access: [public]
@@ -109,7 +109,7 @@ lines:
situation: [routine, investigation]
tags: [tutorial, investigation, orientation]
- id: commission-kiosk_m_d_010
- id: pc-detective_m_d_010
text: "Personnel movement log. Every authorized access to every restricted space, timestamped."
role: player_character
access: [public]
@@ -120,7 +120,7 @@ lines:
# --- Time Idle / Ruminative ---
- id: commission-kiosk_m_d_011
- id: pc-detective_m_d_011
text: "Pattern is forming. Three manifests, same routing anomaly, different filing dates. That's deliberate."
role: player_character
access: [public]
@@ -129,7 +129,7 @@ lines:
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_012
- id: pc-detective_m_d_012
text: "The kiosk shows what the system knows. The system doesn't know what it hasn't been told."
role: player_character
access: [public]
@@ -138,7 +138,7 @@ lines:
situation: [routine]
tags: [atmospheric, investigation]
- id: commission-kiosk_m_d_013
- id: pc-detective_m_d_013
text: "Commission protocols say: document everything, infer nothing. The gap between those two is where cases live."
role: player_character
access: [public]
@@ -147,7 +147,7 @@ lines:
situation: [routine]
tags: [atmospheric, analytical]
- id: commission-kiosk_m_d_014
- id: pc-detective_m_d_014
text: "Twelve access events in the manifest terminal in the last six days. Elevated for a district this size."
role: player_character
access: [public]
@@ -156,7 +156,7 @@ lines:
situation: [routine, investigation]
tags: [investigation, analytical]
- id: commission-kiosk_m_d_015
- id: pc-detective_m_d_015
text: "Evidence is what the system logged. Inference is what it means. Right now I have plenty of both."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
# --- Anomaly ---
- id: commission-kiosk_m_d_016
- id: pc-detective_m_d_016
text: "Access log shows a non-Commission credential used at 0340. That terminal should have rejected it."
role: player_character
access: [public]
@@ -177,7 +177,7 @@ lines:
priority: 7
tags: [investigation, caution, analytical]
- id: commission-kiosk_m_d_017
- id: pc-detective_m_d_017
text: "Container 4471 has three separate manifest entries with different timestamps. One of them is false."
role: player_character
access: [public]
@@ -191,7 +191,7 @@ lines:
priority: 8
tags: [investigation, contraband, analytical]
- id: commission-kiosk_m_d_018
- id: pc-detective_m_d_018
text: "Standard access log would show calibration events. This one shows something cleared three days ago. Manually."
role: player_character
access: [public]
@@ -202,7 +202,7 @@ lines:
# --- Knowledge-Gated: Sera Arc ---
- id: commission-kiosk_m_d_019
- id: pc-detective_m_d_019
text: "Venn, S. — last calibration event logged here: 0615 this morning. Standard. Her schedule is precise."
role: player_character
access: [public]
@@ -216,7 +216,7 @@ lines:
priority: 5
tags: [npc, sera, routine, friend-arc]
- id: commission-kiosk_m_d_020
- id: pc-detective_m_d_020
text: "Venn's credentials are in the access log. Three times today. She's thorough, or she's looking for something."
role: player_character
access: [public]
@@ -230,7 +230,7 @@ lines:
priority: 6
tags: [npc, sera, investigation, friend-arc, dual-lens]
- id: commission-kiosk_m_d_021
- id: pc-detective_m_d_021
text: "She runs calibration checks on the Commission terminal. That's her job. But the access log shows she's also running manifest queries. That's not."
role: player_character
access: [public]
@@ -244,7 +244,7 @@ lines:
priority: 7
tags: [npc, sera, tell, investigation, friend-arc]
- id: commission-kiosk_m_d_022
- id: pc-detective_m_d_022
text: "Venn cleared a flag on container 4471 six days ago. Routine override — authorized. But 4471 is in my manifest discrepancy list."
role: player_character
access: [public]
@@ -260,7 +260,7 @@ lines:
priority: 9
tags: [npc, sera, investigation, contraband, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_023
- id: pc-detective_m_d_023
text: "She knows. Either she found it and cleared it deliberately, or she was directed to. Both are worse than I want to believe."
role: player_character
access: [public]
@@ -278,7 +278,7 @@ lines:
# --- Post-Conversation ---
- id: commission-kiosk_m_d_024
- id: pc-detective_m_d_024
text: "She said she hadn't touched the manifest terminal. The log says otherwise. Either she forgot or she lied."
role: player_character
access: [public]
@@ -292,7 +292,7 @@ lines:
priority: 9
tags: [npc, sera, post-conversation, tell, friend-arc, contaminated-trust]
- id: commission-kiosk_m_d_025
- id: pc-detective_m_d_025
text: "Davan said he runs a clean dock. The manifest data disagrees on three specific points. Filed."
role: player_character
access: [public]
@@ -0,0 +1,236 @@
character: detective
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
#
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
#
# Phase 2 lines: trust established but not deep — the blindsiding.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_001
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
natural in a surprise moment even for the analytical detective — the observation breaks
through the professional register. No interpretation, no accusation.
- id: pc-detective_m_d_con_002
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
notes: >
Alternate primary — fully analytical register: surname-initial for both (institutional
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
detective's version of stating contradiction — clinical, process-language. Fires as
alternate to con_001 for variety on replay.
- id: pc-detective_m_d_con_003
text: "She placed Kael at the dock. He's not at the dock."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2]
notes: >
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
of contradiction. The brevity is the weight: six words and the investigation changes shape.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_004
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [anxious, focused]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
the key line — it holds accusation off even against evidence. The detective files it open.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-detective_m_d_con_005
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
information — the detective separates these without assuming either. 'That's a different
question' closes the processing loop, marking it unresolved, not dismissed.
# -----------------------------------------------------------------------
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
# -----------------------------------------------------------------------
- id: pc-detective_m_d_con_006
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
notes: >
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
for the friend relationship — vs. the clinical observation about Lintar.
- id: pc-detective_m_d_con_007
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
notes: >
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
without concluding. The numbered format ('Two facts... One new observation') is the
detective's internal case-building style. Fully non-accusatory: the connection is not
stated, only the intent to look for it.
- id: pc-detective_m_d_con_008
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
- id: "npc:sera-venn"
state: Contradicted
- id: "npc:torek-lintar"
relationship: PersonOfInterest
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
notes: >
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
the detective naming Sera's avoidance behavior — behavioral description, not motive
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
remains unstated. Detective is following the thread, not announcing what's on it.
@@ -1,7 +1,7 @@
character: detective
location: general
lines:
- id: general_m_d_001
- id: pc-detective_m_d_001
text: "Sova Transit District. Population twelve thousand and change. Let's narrow that down."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: general_m_d_002
- id: pc-detective_m_d_002
text: "The span gate resonance is constant here. Background radiation of a freight hub."
role: player_character
access: [public]
@@ -19,7 +19,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: general_m_d_003
- id: pc-detective_m_d_003
text: "First week on station. The case file is thin. The manifest discrepancies are not."
role: player_character
access: [public]
@@ -28,7 +28,7 @@ lines:
trigger: time_idle
tags: [investigation, orientation]
- id: general_m_d_004
- id: pc-detective_m_d_004
text: "Everyone knows I'm Commission. The conversations stop when I walk in. Noted."
role: player_character
access: [public]
@@ -37,7 +37,7 @@ lines:
trigger: time_idle
tags: [investigation, social]
- id: general_m_d_005
- id: pc-detective_m_d_005
text: "Station gravity's point-nine-three standard. Enough to notice after a long shift."
role: player_character
access: [public]
@@ -46,7 +46,7 @@ lines:
trigger: time_idle
tags: [environmental, personal]
- id: general_m_d_006
- id: pc-detective_m_d_006
text: "The previous liaison rotated out three months ago. That gap matters."
role: player_character
access: [public]
@@ -55,7 +55,7 @@ lines:
trigger: time_idle
tags: [investigation, analytical]
- id: general_m_d_007
- id: pc-detective_m_d_007
text: "Back in the district. Let's see what changed while I wasn't watching."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
trigger: return_visit
tags: [arrival, analytical]
- id: general_m_d_008
- id: pc-detective_m_d_008
text: "Recycled air, industrial residue, and that constant gate hum. Sova in three notes."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: general_m_d_009
- id: pc-detective_m_d_009
text: "Communities like this close ranks around outsiders. Patient work."
role: player_character
access: [public]
@@ -82,7 +82,7 @@ lines:
trigger: time_idle
tags: [reflection, social]
- id: general_m_d_010
- id: pc-detective_m_d_010
text: "The manifest data goes back eighteen months. The pattern starts around month six."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: maintenance-corridors
lines:
# --- Arrival + Environmental Flavor ---
- id: maintenance-corridors_m_d_001
- id: pc-detective_m_d_001
text: "Maintenance level. Low lighting, exposed conduit, restricted access nobody enforces."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, environmental]
- id: maintenance-corridors_m_d_002
- id: pc-detective_m_d_002
text: "The air's different down here. Cooler. Damper. Less filtered."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_d_003
- id: pc-detective_m_d_003
text: "Service corridor B-section. Not on the public transit map. Interesting routing options."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: maintenance-corridors_m_d_004
- id: pc-detective_m_d_004
text: "Water recycling conduits. The station's circulatory system, hidden from view."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: maintenance-corridors_m_d_005
- id: pc-detective_m_d_005
text: "Pipe network branching three directions. Good for maintenance. Better for privacy."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: enter_location
tags: [environmental, analytical]
- id: maintenance-corridors_m_d_006
- id: pc-detective_m_d_006
text: "Emergency lighting only. Visibility reduced, but so is surveillance coverage."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: enter_location
tags: [environmental, investigation]
- id: maintenance-corridors_m_d_007
- id: pc-detective_m_d_007
text: "The rust smell is stronger near junction B-7. Older section. Less maintenance."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_d_008
- id: pc-detective_m_d_008
text: "Back in the corridors. Different route this time — mapping the alternatives."
role: player_character
access: [public]
@@ -77,7 +77,7 @@ lines:
# --- Investigation Observations ---
- id: maintenance-corridors_m_d_009
- id: pc-detective_m_d_009
text: "Scuff marks on the floor. Recent, heavy. Cargo weight, not foot traffic."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_010
- id: pc-detective_m_d_010
text: "The service hatch has been used recently. Oil on the handle, dust disturbed."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_011
- id: pc-detective_m_d_011
text: "Restricted access, but the lock plate shows wear. Regular use by someone with clearance."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_012
- id: pc-detective_m_d_012
text: "Sound carries differently down here. Voices from two junctions away, if you're patient."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: hear_sound
tags: [environmental, investigation]
- id: maintenance-corridors_m_d_013
- id: pc-detective_m_d_013
text: "Chalk marks on the wall. Low, deliberate. Not maintenance notation — wrong color."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_anomaly
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_014
- id: pc-detective_m_d_014
text: "This corridor connects bay four to temp storage. Bypasses the main freight floor entirely."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: enter_location
tags: [investigation, operational]
- id: maintenance-corridors_m_d_015
- id: pc-detective_m_d_015
text: "Footsteps ahead. Single set, moving fast. Someone who knows where they're going."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: hear_sound
tags: [investigation, caution]
- id: maintenance-corridors_m_d_016
- id: pc-detective_m_d_016
text: "The temp storage access from this side isn't logged on the manifest system. Blind spot."
role: player_character
access: [public]
@@ -151,7 +151,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: maintenance-corridors_m_d_017
- id: pc-detective_m_d_017
text: "The cargo scuff pattern matches bay four containers. Dock to corridor to storage — the route."
role: player_character
access: [public]
@@ -165,7 +165,7 @@ lines:
priority: 8
tags: [investigation, evidence, breakthrough]
- id: maintenance-corridors_m_d_018
- id: pc-detective_m_d_018
text: "Those chalk marks match the courier's timing. Renn's route markers, if I had to guess."
role: player_character
access: [public]
@@ -179,7 +179,7 @@ lines:
priority: 7
tags: [npc, renn, investigation, evidence]
- id: maintenance-corridors_m_d_019
- id: pc-detective_m_d_019
text: "Camera blind spot at junction C-2. Coincidence, or someone mapped the coverage?"
role: player_character
access: [public]
@@ -192,7 +192,7 @@ lines:
min_confidence: suspects
tags: [investigation, operational]
- id: maintenance-corridors_m_d_020
- id: pc-detective_m_d_020
text: "Standard container, no manifest checkpoint along this route. That's the gap."
role: player_character
access: [public]
@@ -206,7 +206,7 @@ lines:
priority: 9
tags: [investigation, breakthrough]
- id: maintenance-corridors_m_d_021
- id: pc-detective_m_d_021
text: "Hatch lubricant is fresh. Someone's maintaining this access point. Not station maintenance."
role: player_character
access: [public]
@@ -219,7 +219,7 @@ lines:
min_confidence: knows_of
tags: [investigation, evidence]
- id: maintenance-corridors_m_d_022
- id: pc-detective_m_d_022
text: "Davan was seen near B-7 during off-shift. Cross-reference with container movements."
role: player_character
access: [public]
@@ -233,7 +233,7 @@ lines:
priority: 7
tags: [npc, kael, investigation]
- id: maintenance-corridors_m_d_023
- id: pc-detective_m_d_023
text: "Two access routes, one manifest checkpoint. Someone designed this gap. Or discovered it."
role: player_character
access: [public]
@@ -246,7 +246,7 @@ lines:
min_confidence: knows_details
tags: [investigation, analytical]
- id: maintenance-corridors_m_d_024
- id: pc-detective_m_d_024
text: "Rosta's gambling debt gives me leverage. But using it makes me the authority they resent."
role: player_character
access: [public]
@@ -260,7 +260,7 @@ lines:
priority: 6
tags: [npc, drin, investigation, moral]
- id: maintenance-corridors_m_d_025
- id: pc-detective_m_d_025
text: "Evidence is accumulating. Names, routes, timing. But I still can't see the full picture."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
# Contraction discipline: analytical lines = uncontracted; personal lines (Sera) = contracted.
# All lines fully schema-compliant per D-035 (role, access, trust, situation present).
- id: opening_m_d_001
- id: pc-detective_m_d_001
text: "Sova Transit. Mid-morning shift, dock lighting at half-cycle. Consistent with the Commission pre-arrival briefing."
role: player_character
access: [public]
@@ -23,7 +23,7 @@ lines:
tags: [opening-hook, tutorial-arc, analytical, orientation]
notes: "Opening line. Reality matches the briefing — he arrived prepared. Establishes: detective as institutional outsider sent here. Cool-lit corridors per D-046 expressed through 'half-cycle' lighting observation. No contractions (analytical)."
- id: opening_m_d_002
- id: pc-detective_m_d_002
text: "Cargo lubricant, recycled atmosphere. Same mixture on every freight hub from here to the span gate hum. First observation: consistent with Commission briefing profile."
role: player_character
access: [public]
@@ -36,7 +36,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric, analytical]
notes: "Environmental sensory in analytical register. Colon usage (detective verbal pattern, section 2.2). No contractions. 'span gate' is canonical terminology (D-036); 'hum' is sensory detail. Contrast with smuggler's 'Home sweet home' — same smell, opposite emotional valence. He's working, not home."
- id: opening_m_d_003
- id: pc-detective_m_d_003
text: "Lattice overlay initializing. Four identity flags in immediate range — cross-referencing against Commission case file."
role: player_character
access: [public]
@@ -49,7 +49,7 @@ lines:
tags: [opening-hook, tutorial-arc, lattice, perception]
notes: "Teaches insert channel. The analytical lattice is active on entry — it flags and cross-references automatically. Teaches: detective's insert HUD is denser and diegetically active (D-048). Players learn the lattice is a tool, not decoration."
- id: opening_m_d_004
- id: pc-detective_m_d_004
text: "Commission kiosk confirmed my credentials on entry. Authority access logged. The floor knows someone's here."
role: player_character
access: [public]
@@ -62,7 +62,7 @@ lines:
tags: [opening-hook, tutorial-arc, institutional, authority]
notes: "Commission kiosk visible, institutional standing established. Sardonic close — dry awareness of his effect on the social environment. Confidence register: wry humor (section 2.8). Teaches: Authority access tier, institutional role is visible to NPCs."
- id: opening_m_d_005
- id: pc-detective_m_d_005
text: "Visual range terminates at bay nine. Approximately fifteen meters. Consistent with district atmo settings."
role: player_character
access: [public]
@@ -74,7 +74,7 @@ lines:
tags: [opening-hook, tutorial-arc, fog, perception]
notes: "Fog boundary in analytical register. He clocks the measurement, notes 'approximately' — precision with honest calibration. Contrast with smuggler's 'Can't see the far dock' — same fog, different framing: limitation vs. measurement."
- id: opening_m_d_006
- id: pc-detective_m_d_006
text: "Dock workers distributed across nine visible bays. Lattice flagging each on contact. Names from the case file, faces from the floor."
role: player_character
access: [public]
@@ -87,7 +87,7 @@ lines:
tags: [opening-hook, tutorial-arc, lattice, orientation]
notes: "Spatial overview with lattice overlay tutorial. Explains how the insert channel works: lattice matches observed faces against the pre-loaded case file. Parallel structure ('Names from... faces from...'). Teaches: NPC identification comes through the lattice."
- id: opening_m_d_007
- id: pc-detective_m_d_007
text: "Venn, S. at the far terminal. She said she'd come in early."
role: player_character
access: [public]
@@ -100,7 +100,7 @@ lines:
tags: [opening-hook, tutorial-arc, sera, friend-arc]
notes: "First Sera sighting. Surname-initial filing first (institutional reflex) — then breaks to personal contracted voice ('she'd') in the same beat. Teaches contrast: case-file subjects get 'Davan, K.'; Sera gets a different register. D-033 Unknown/Neutral teal entity."
- id: opening_m_d_008
- id: pc-detective_m_d_008
text: "She's got the manifest logs open already. Field tech who knows how to pull records — she knows what I need before I ask."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
tags: [opening-hook, tutorial-arc, sera, friend-arc]
notes: "Sera warmth. Fully contracted throughout ('She's', 'she knows') — personal mode, no analytical distance. Em-dash. Professional respect established without implying prior district visits. Contrast with how he processes everyone else. Teaches: some NPCs are in a different relational category (THE FRIEND pattern, D-034)."
- id: opening_m_d_009
- id: pc-detective_m_d_009
text: "Central manifest board: real-time container routing, weight declarations, bay assignments. Everything routes through here."
role: player_character
access: [public]
@@ -126,7 +126,7 @@ lines:
tags: [opening-hook, tutorial-arc, manifest, investigation]
notes: "Manifest board labeled and cataloged. Colon usage (section 2.2). No contractions (analytical). Teaches: the manifest board is the information hub — and therefore the investigation's starting point."
- id: opening_m_d_010
- id: pc-detective_m_d_010
text: "Container 4471. Temp storage flag since yesterday. Standard turnaround is sub-eight hours — that's not standard."
role: player_character
access: [public]
@@ -139,7 +139,7 @@ lines:
tags: [opening-hook, tutorial-arc, manifest, investigation, anomaly]
notes: "First manifest anomaly. Specific container ID from case file. 'That's not standard' — only contraction in an analytical line, marking it as personal reaction to the finding. Sets the investigation hook: something is wrong before he asks a single question."
- id: opening_m_d_011
- id: pc-detective_m_d_011
text: "Worth flagging. Let's see what the shift logs say about that container's overnight routing."
role: player_character
access: [public]
@@ -152,7 +152,7 @@ lines:
tags: [opening-hook, tutorial-arc, investigation, next-step]
notes: "'Worth flagging' + 'Let's see what' — both detective verbal tics in sequence (section 2.7). Frames the next investigative step. Teaches: investigation is incremental — observe, flag, investigate deeper."
- id: opening_m_d_012
- id: pc-detective_m_d_012
text: "Lattice stable. Nine identity flags and counting. Let's see what this shift tells me."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
# --- Tell Observations (5 core tells from pc-as-npc-spec.md section 8) ---
- id: general_m_d_011
- id: pc-detective_m_d_001
text: "That dock worker checks the corridor before turning. Habitual. Military? Or just careful."
role: player_character
access: [public]
@@ -25,7 +25,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, corridor-check]
- id: general_m_d_012
- id: pc-detective_m_d_002
text: "Movement pattern changed at shift change. More direct. Less idle. Interesting."
role: player_character
access: [public]
@@ -38,7 +38,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, shift-transition]
- id: general_m_d_013
- id: pc-detective_m_d_003
text: "Slight acceleration past the checkpoint. Most people do it. But this one does it every time."
role: player_character
access: [public]
@@ -51,7 +51,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, security-proximity]
- id: general_m_d_014
- id: pc-detective_m_d_004
text: "Same three people at the same table. Every shift. Consistent social group."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
state: known
tags: [npc, pc-smuggler, tell, social-clustering]
- id: general_m_d_015
- id: pc-detective_m_d_005
text: "That worker checked container 4471 twice. The same container that's in my manifest discrepancy file."
role: player_character
access: [public]
@@ -83,7 +83,7 @@ lines:
# --- Accumulated Observation (gates on multiple tell sightings) ---
- id: general_m_d_016
- id: pc-detective_m_d_006
text: "Three behavioral flags on one dock worker. Corridor awareness, shift-transition alertness, cargo fixation. That's a pattern."
role: player_character
access: [public]
@@ -98,7 +98,7 @@ lines:
priority: 7
tags: [npc, pc-smuggler, investigation, analytical, pattern-recognition]
- id: general_m_d_017
- id: pc-detective_m_d_007
text: "Cross-referencing the dock worker's shift schedule with manifest anomalies. Correlation: moderate."
role: player_character
access: [public]
@@ -115,7 +115,7 @@ lines:
priority: 8
tags: [npc, pc-smuggler, investigation, evidence]
- id: general_m_d_018
- id: pc-detective_m_d_008
text: "The dock worker and Davan eat together every shift. Same courier joins them. Tight operational cluster."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-last-shift
lines:
# --- Arrival + Environmental Flavor ---
- id: the-last-shift_m_d_001
- id: pc-detective_m_d_001
text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-last-shift_m_d_002
- id: pc-detective_m_d_002
text: "Grain spirit and low conversation. The social nexus of Sova Transit."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: the-last-shift_m_d_003
- id: pc-detective_m_d_003
text: "Bar's busy tonight. Good — crowds talk. Bad — they notice outsiders."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: the-last-shift_m_d_004
- id: pc-detective_m_d_004
text: "Standard district bar. Prefab fixtures, aftermarket lighting, a menu board nobody reads."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: enter_location
tags: [arrival, environmental]
- id: the-last-shift_m_d_005
- id: pc-detective_m_d_005
text: "The Meridian feed on the wall display. Local news, freight indices, Assembly coverage."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: enter_location
tags: [environmental]
- id: the-last-shift_m_d_006
- id: pc-detective_m_d_006
text: "The overhead vent rattles every thirty seconds. Like clockwork. Nobody else notices."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: the-last-shift_m_d_007
- id: pc-detective_m_d_007
text: "The conversation volume dropped when I walked in. Recovering now. They're adjusting."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [social, investigation]
- id: the-last-shift_m_d_008
- id: pc-detective_m_d_008
text: "Back at the bar. Same booth. Let's see who's here and who isn't."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: return_visit
tags: [arrival, analytical]
- id: the-last-shift_m_d_009
- id: pc-detective_m_d_009
text: "Kitchen's running. Fried protein and something spiced. The bar's other currency: hot food."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: the-last-shift_m_d_010
- id: pc-detective_m_d_010
text: "Someone carved initials into this table. Old. A history I don't have."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
# --- NPC Commentary ---
- id: the-last-shift_m_d_011
- id: pc-detective_m_d_011
text: "The bartender — Sessik, L. Knows everyone. Watches everything. Gives away nothing."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: observe_npc
tags: [npc, lera, analytical]
- id: the-last-shift_m_d_012
- id: pc-detective_m_d_012
text: "Good to see Sera. At least someone in this district speaks my language."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: observe_npc
tags: [npc, sera, warm]
- id: the-last-shift_m_d_013
- id: pc-detective_m_d_013
text: "Lintar, T. — the loud one at the bar. Spends freely. Where's the money from?"
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, torek, investigation]
- id: the-last-shift_m_d_014
- id: pc-detective_m_d_014
text: "A woman at the corner table — Tamm, N., according to Sera. Davan's partner."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, naia, analytical]
- id: the-last-shift_m_d_015
- id: pc-detective_m_d_015
text: "The regulars have assigned seating. Unofficial, but enforced. I don't have a spot yet."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [social, atmospheric]
- id: the-last-shift_m_d_016
- id: pc-detective_m_d_016
text: "Sessik greeted three people by name since I sat down. I got a nod. Progress."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: witness_interaction
tags: [npc, lera, social]
- id: the-last-shift_m_d_017
- id: pc-detective_m_d_017
text: "Two dock workers in the corner. Heads close, voices low. Not unusual for after-shift."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-last-shift_m_d_018
- id: pc-detective_m_d_018
text: "Sera introduced me to Tamm. Brief, polite, guarded. Expected."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: post_conversation
tags: [npc, sera, naia, social]
- id: the-last-shift_m_d_019
- id: pc-detective_m_d_019
text: "Sessik poured my drink without asking. She remembered. Small thing, but noted."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: observe_npc
tags: [npc, lera, social, warm]
- id: the-last-shift_m_d_020
- id: pc-detective_m_d_020
text: "The old man by the window hasn't spoken to anyone all evening. Watching, though."
role: player_character
access: [public]
@@ -187,7 +187,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-last-shift_m_d_021
- id: pc-detective_m_d_021
text: "Sera left when Torek arrived. Second time. Different excuse. Same result."
role: player_character
access: [public]
@@ -201,7 +201,7 @@ lines:
priority: 7
tags: [npc, sera, torek, tell, friend-arc]
- id: the-last-shift_m_d_022
- id: pc-detective_m_d_022
text: "That's the third time. Three excuses, one result. That's data."
role: player_character
access: [public]
@@ -215,7 +215,7 @@ lines:
priority: 9
tags: [npc, sera, torek, tell, friend-arc, contradiction]
- id: the-last-shift_m_d_023
- id: pc-detective_m_d_023
text: "Lintar's spending doesn't match a dock worker's salary. Source unknown."
role: player_character
access: [public]
@@ -228,7 +228,7 @@ lines:
min_confidence: knows_of
tags: [npc, torek, investigation]
- id: the-last-shift_m_d_024
- id: pc-detective_m_d_024
text: "Sessik's eyes tracked me when I sat near the back room. Protective, not hostile."
role: player_character
access: [public]
@@ -241,7 +241,7 @@ lines:
min_confidence: suspects
tags: [npc, lera, investigation]
- id: the-last-shift_m_d_025
- id: pc-detective_m_d_025
text: "Sera's being extra helpful tonight. Names, backgrounds, context. Compensating?"
role: player_character
access: [public]
@@ -255,7 +255,7 @@ lines:
priority: 7
tags: [npc, sera, friend-arc, behavioral]
- id: the-last-shift_m_d_026
- id: pc-detective_m_d_026
text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth."
role: player_character
access: [public]
@@ -269,7 +269,7 @@ lines:
priority: 8
tags: [npc, sera, contaminated-trust, friend-arc]
- id: the-last-shift_m_d_027
- id: pc-detective_m_d_027
text: "Tamm seems distressed tonight. Not her baseline. Something at home, or something she heard?"
role: player_character
access: [public]
@@ -282,7 +282,7 @@ lines:
state: known
tags: [npc, naia, behavioral]
- id: the-last-shift_m_d_028
- id: pc-detective_m_d_028
text: "Sessik just redirected a conversation away from freight schedules. Protective reflex?"
role: player_character
access: [public]
@@ -295,7 +295,7 @@ lines:
min_confidence: suspects
tags: [npc, lera, investigation]
- id: the-last-shift_m_d_029
- id: pc-detective_m_d_029
text: "Davan and his partner at the bar. She's talking. He's not listening. Noted."
role: player_character
access: [public]
@@ -308,7 +308,7 @@ lines:
state: known
tags: [npc, kael, naia, behavioral]
- id: the-last-shift_m_d_030
- id: pc-detective_m_d_030
text: "The regulars are warming up. Three visits and I'm almost furniture. Almost."
role: player_character
access: [public]
@@ -324,7 +324,7 @@ lines:
# --- Kael Davan — Additional Bar Observations ---
# Ticket: #297 review fix | Filling detective monologue gap
- id: the-last-shift_m_d_043
- id: pc-detective_m_d_031
text: "Davan is different here. Looser. The dock worker persona drops and something warmer replaces it."
role: player_character
access: [public]
@@ -337,7 +337,7 @@ lines:
state: known
tags: [npc, kael, behavioral, dual-context]
- id: the-last-shift_m_d_044
- id: pc-detective_m_d_032
text: "Davan checks the door every time it opens. Even here. Even off-shift. That is not relaxation."
role: player_character
access: [public]
@@ -357,7 +357,7 @@ lines:
# 5-phase FRIEND arc: trust > background data > pattern > question >
# contaminated trust. Contraction shifts mark objectivity loss.
- id: the-last-shift_m_d_031
- id: pc-detective_m_d_033
text: "Sera changed the subject when I mentioned manifests. Smooth. Practised, maybe."
role: player_character
access: [public]
@@ -371,7 +371,7 @@ lines:
priority: 6
tags: [npc, sera, tell, behavioral, friend-arc]
- id: the-last-shift_m_d_032
- id: pc-detective_m_d_034
text: "She volunteers names, routines, context. Gives everything I ask for -- except what I did not."
role: player_character
access: [public]
@@ -385,7 +385,7 @@ lines:
priority: 6
tags: [npc, sera, tell, overcompensation, friend-arc]
- id: the-last-shift_m_d_033
- id: pc-detective_m_d_035
text: "Sera at the Commission kiosk. Third pass this shift. That is not standard recalibration."
role: player_character
access: [public]
@@ -399,7 +399,7 @@ lines:
priority: 7
tags: [npc, sera, tell, peripheral, friend-arc]
- id: the-last-shift_m_d_034
- id: pc-detective_m_d_036
text: "Everything Sera told me checks out. Layout, schedules, regulars. Accurate. Thorough. Selective?"
role: player_character
access: [public]
@@ -413,7 +413,7 @@ lines:
priority: 5
tags: [npc, sera, analytical, phase-2-3, friend-arc]
- id: the-last-shift_m_d_035
- id: pc-detective_m_d_037
text: "She mentioned Naia's worry about Kael early -- before I had context. Priming, or concern?"
role: player_character
access: [public]
@@ -427,7 +427,7 @@ lines:
priority: 7
tags: [npc, sera, naia, retrospective, friend-arc]
- id: the-last-shift_m_d_036
- id: pc-detective_m_d_038
text: "Sera's institutional access goes further than field calibration. She could pull records I cannot."
role: player_character
access: [public]
@@ -440,7 +440,7 @@ lines:
state: friendly
tags: [npc, sera, analytical, capability, friend-arc]
- id: the-last-shift_m_d_037
- id: pc-detective_m_d_039
text: "Assessment: Venn, S. Cooperative. Embedded. Competent. Green -- with reservations now."
role: player_character
access: [public]
@@ -454,7 +454,7 @@ lines:
priority: 7
tags: [npc, sera, phase-transition, analytical, friend-arc]
- id: the-last-shift_m_d_038
- id: pc-detective_m_d_040
text: "Correction: amber. The avoidance pattern is behavioural, not preferential. Data, not taste."
role: player_character
access: [public]
@@ -468,7 +468,7 @@ lines:
priority: 9
tags: [npc, sera, phase-transition, tell, contradiction, friend-arc]
- id: the-last-shift_m_d_039
- id: pc-detective_m_d_041
text: "She's the first person who made this place feel less foreign. I need that to not matter right now."
role: player_character
access: [public]
@@ -482,7 +482,7 @@ lines:
priority: 8
tags: [npc, sera, emotional, phase-4-5, friend-arc]
- id: the-last-shift_m_d_040
- id: pc-detective_m_d_042
text: "Those early tips. The district layout, the introductions. Were those help -- or steering?"
role: player_character
access: [public]
@@ -496,7 +496,7 @@ lines:
priority: 7
tags: [npc, sera, contaminated-trust, retrospective, friend-arc]
- id: the-last-shift_m_d_041
- id: pc-detective_m_d_043
text: "She's not a suspect. She's a friend with access to everything I need. That is the problem."
role: player_character
access: [public]
@@ -510,7 +510,7 @@ lines:
priority: 8
tags: [npc, sera, contaminated-trust, phase-4-5, friend-arc]
- id: the-last-shift_m_d_042
- id: pc-detective_m_d_044
text: "Trust: compromised. The question is not what she told me. It is what she did not."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: the-terminal
lines:
# --- Arrival + Environmental Flavor ---
- id: the-terminal_m_d_001
- id: pc-detective_m_d_001
text: "Logistics hub. Standard prefab, heavy foot traffic. Let's see what the shift change tells me."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, analytical]
- id: the-terminal_m_d_002
- id: pc-detective_m_d_002
text: "Sixteen bays, four loading arms, one manifest board. Everything routes through here."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [arrival, orientation]
- id: the-terminal_m_d_003
- id: pc-detective_m_d_003
text: "Cargo lubricant and recycled air. The smell of honest work. Or a convincing imitation."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: the-terminal_m_d_004
- id: pc-detective_m_d_004
text: "Loading arm three has a grind in its cycle. Maintenance deferred — budget, or negligence?"
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental, analytical]
- id: the-terminal_m_d_005
- id: pc-detective_m_d_005
text: "Shift change in twenty minutes. The handover period is where oversight gets thin."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: time_idle
tags: [investigation, operational]
- id: the-terminal_m_d_006
- id: pc-detective_m_d_006
text: "The manifest board updates in real-time. Container movement, weight, destination. All logged."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: enter_location
tags: [environmental, orientation]
- id: the-terminal_m_d_007
- id: pc-detective_m_d_007
text: "Heavy foot traffic at shift change. Good — observation is easier in a crowd."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [arrival, investigation]
- id: the-terminal_m_d_008
- id: pc-detective_m_d_008
text: "Containers stacked to regulation height in most bays. Bay four is the exception."
role: player_character
access: [public]
@@ -75,7 +75,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, investigation]
- id: the-terminal_m_d_009
- id: pc-detective_m_d_009
text: "Back at the hub. Different shift, different faces. Same manifest board."
role: player_character
access: [public]
@@ -84,7 +84,7 @@ lines:
trigger: return_visit
tags: [arrival]
- id: the-terminal_m_d_010
- id: pc-detective_m_d_010
text: "The dock lights are running half-cycle. Saves power. Also reduces visibility."
role: player_character
access: [public]
@@ -93,7 +93,7 @@ lines:
trigger: enter_location
tags: [environmental, analytical]
- id: the-terminal_m_d_011
- id: pc-detective_m_d_011
text: "Something in the air beyond the lubricant. Chemical, faint. Not standard freight residue."
role: player_character
access: [public]
@@ -102,7 +102,7 @@ lines:
trigger: enter_location
tags: [environmental, investigation]
- id: the-terminal_m_d_012
- id: pc-detective_m_d_012
text: "Overhead conveyor running at capacity. The manifest says otherwise. Interesting."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
# --- NPC Commentary ---
- id: the-terminal_m_d_013
- id: pc-detective_m_d_013
text: "Shift supervisor Voss. Tense posture, checking the dock more than the schedule."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: observe_npc
tags: [npc, voss, behavioral]
- id: the-terminal_m_d_014
- id: pc-detective_m_d_014
text: "Korr, M. — freight scheduler. Efficient, focused. Either very good or very careful."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_npc
tags: [npc, maret, analytical]
- id: the-terminal_m_d_015
- id: pc-detective_m_d_015
text: "Rosta, D. — dock inspector. Quick checks, minimal documentation. That's a pattern."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: observe_npc
tags: [npc, drin, investigation]
- id: the-terminal_m_d_016
- id: pc-detective_m_d_016
text: "Dock worker. Davan, K. Unremarkable on paper. But he knows the bay layout cold."
role: player_character
access: [public]
@@ -149,7 +149,7 @@ lines:
trigger: observe_npc
tags: [npc, kael]
- id: the-terminal_m_d_017
- id: pc-detective_m_d_017
text: "Conversations stop when I pass. Standard response to Commission presence. Expected."
role: player_character
access: [public]
@@ -158,7 +158,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-terminal_m_d_018
- id: pc-detective_m_d_018
text: "Two workers talking by bay six. One sees me, taps the other. Both go quiet."
role: player_character
access: [public]
@@ -167,7 +167,7 @@ lines:
trigger: witness_interaction
tags: [social, investigation]
- id: the-terminal_m_d_019
- id: pc-detective_m_d_019
text: "Voss gave Davan a look across the bay. Brief. Loaded. Filing it."
role: player_character
access: [public]
@@ -176,7 +176,7 @@ lines:
trigger: witness_interaction
tags: [npc, voss, kael, behavioral]
- id: the-terminal_m_d_020
- id: pc-detective_m_d_020
text: "The courier — Renn — moves fast. Efficient routing. Knows these corridors well."
role: player_character
access: [public]
@@ -185,7 +185,7 @@ lines:
trigger: observe_npc
tags: [npc, renn, analytical]
- id: the-terminal_m_d_021
- id: pc-detective_m_d_021
text: "New shift starting. Different bodies, same avoidance radius around me."
role: player_character
access: [public]
@@ -194,7 +194,7 @@ lines:
trigger: observe_npc
tags: [social, atmospheric]
- id: the-terminal_m_d_022
- id: pc-detective_m_d_022
text: "Rosta's inspection of bay four took forty seconds. The others: three minutes each."
role: player_character
access: [public]
@@ -203,7 +203,7 @@ lines:
trigger: observe_npc
tags: [npc, drin, investigation, behavioral]
- id: the-terminal_m_d_023
- id: pc-detective_m_d_023
text: "Voss deflected when I asked about overnight containers. Smooth, but I caught it."
role: player_character
access: [public]
@@ -212,7 +212,7 @@ lines:
trigger: post_conversation
tags: [npc, voss, investigation]
- id: the-terminal_m_d_024
- id: pc-detective_m_d_024
text: "Korr won't make eye contact. Not hostility — something else. Anxiety, maybe."
role: player_character
access: [public]
@@ -223,7 +223,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: the-terminal_m_d_025
- id: pc-detective_m_d_025
text: "Davan's lattice activity spiked. Expecting a message? Or checking for surveillance?"
role: player_character
access: [public]
@@ -236,7 +236,7 @@ lines:
state: known
tags: [npc, kael, behavioral, tell]
- id: the-terminal_m_d_026
- id: pc-detective_m_d_026
text: "Container 4471. Logged for temp storage overnight. Turnaround should be sub-eight hours."
role: player_character
access: [public]
@@ -250,7 +250,7 @@ lines:
priority: 7
tags: [investigation, evidence]
- id: the-terminal_m_d_027
- id: pc-detective_m_d_027
text: "Weight discrepancy on the bay four manifest. Point-three kilos over declared. Noted."
role: player_character
access: [public]
@@ -263,7 +263,7 @@ lines:
min_confidence: suspects
tags: [investigation, evidence]
- id: the-terminal_m_d_028
- id: pc-detective_m_d_028
text: "Rosta's gambling debts are in the case file. Leverage, if I need it. Do I need it?"
role: player_character
access: [public]
@@ -276,7 +276,7 @@ lines:
min_confidence: knows_details
tags: [npc, drin, investigation, moral]
- id: the-terminal_m_d_029
- id: pc-detective_m_d_029
text: "Three names keep appearing near the anomalous manifests: Voss, Davan, Renn. Coincidence?"
role: player_character
access: [public]
@@ -290,7 +290,7 @@ lines:
priority: 7
tags: [investigation, analytical]
- id: the-terminal_m_d_030
- id: pc-detective_m_d_030
text: "The shift transition creates a twenty-minute gap in active oversight. Every cycle."
role: player_character
access: [public]
@@ -304,7 +304,7 @@ lines:
priority: 7
tags: [investigation, operational]
- id: the-terminal_m_d_031
- id: pc-detective_m_d_031
text: "Korr changed the schedule after our conversation. Covering tracks, or just doing her job?"
role: player_character
access: [public]
@@ -318,7 +318,7 @@ lines:
value: schedule_modification
tags: [npc, maret, investigation]
- id: the-terminal_m_d_032
- id: pc-detective_m_d_032
text: "Rosta inspects everything else by the book. Bay four is the exception. That's the tell."
role: player_character
access: [public]
@@ -332,7 +332,7 @@ lines:
priority: 8
tags: [npc, drin, investigation, behavioral]
- id: the-terminal_m_d_033
- id: pc-detective_m_d_033
text: "Davan's at an unusual terminal. Not his assigned bay. Why the move?"
role: player_character
access: [public]
@@ -345,7 +345,7 @@ lines:
state: known
tags: [npc, kael, investigation]
- id: the-terminal_m_d_034
- id: pc-detective_m_d_034
text: "Voss is watching me watch his people. He knows what I'm here for."
role: player_character
access: [public]
@@ -359,7 +359,7 @@ lines:
priority: 6
tags: [npc, voss, investigation]
- id: the-terminal_m_d_035
- id: pc-detective_m_d_035
text: "The routing logs and the physical movement don't match. Someone's hand-editing."
role: player_character
access: [public]
@@ -376,7 +376,7 @@ lines:
# --- Kael Davan FRIEND Arc — Additional Detective Observations ---
# Ticket: #297 review fix | Filling detective monologue gap (7 → 12 Kael lines)
- id: the-terminal_m_d_036
- id: pc-detective_m_d_036
text: "Davan's shift pattern is clean. Too clean. Same bays, same hours, same rotation. Nothing out of place."
role: player_character
access: [public]
@@ -389,7 +389,7 @@ lines:
state: known
tags: [npc, kael, analytical, behavioral]
- id: the-terminal_m_d_037
- id: pc-detective_m_d_037
text: "Davan eats with the same group every shift. Social anchor, or keeping tabs?"
role: player_character
access: [public]
@@ -402,7 +402,7 @@ lines:
state: known
tags: [npc, kael, social, behavioral]
- id: the-terminal_m_d_038
- id: pc-detective_m_d_038
text: "Davan handled that container routing without checking the manifest board. He knows the schedule by heart."
role: player_character
access: [public]
@@ -416,7 +416,7 @@ lines:
priority: 6
tags: [npc, kael, behavioral, tell, competence]
- id: the-terminal_m_d_039
- id: pc-detective_m_d_039
text: "Davan's posture changed when I approached bay four. Subtle. Filing it."
role: player_character
access: [public]
@@ -430,7 +430,7 @@ lines:
priority: 7
tags: [npc, kael, tell, behavioral, friend-arc]
- id: the-terminal_m_d_040
- id: pc-detective_m_d_040
text: "Three manifests with weight discrepancies. All during Davan's shifts. Correlation is not causation. But noted."
role: player_character
access: [public]
@@ -0,0 +1,243 @@
character: detective
location: tutorial
lines:
# Diegetic Tutorial Monologue — Ticket #330 — Detective
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
#
# These lines teach mechanics through character voice — not UI instructions.
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
# These fire on first-time events throughout gameplay, wherever they occur.
#
# Coverage:
# Movement / exploration — first_move, first_new_section
# Fog of perception — first_fog_encounter, first_fog_edge
# Sound model — first_sound_heard, first_off_screen_sound
# NPC interaction — first_npc_proximity, first_npc_face_read
# Insert / HUD — first_insert_open, first_insert_contact_flag
#
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
# ID discriminator: _tut_ per sprint briefing.
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
# fire once per game), not by a cooldown value.
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
#
# Schema corrections (Gestalt review, Sprint 17):
# priority: string "tutorial" → integer. All other monologue files use integer priority;
# string value would fail engine parse in monologue.rs.
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
# enum. Replaced with arrival, routine per actual trigger context.
#
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
# D-035 v0.1 set — require server-side implementation in monologue.rs.
# -----------------------------------------------------------------------
# MOVEMENT / EXPLORATION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_001
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
role: player_character
access: [public]
trust: surface
situation: [arrival, routine]
trigger: first_move
mood: [focused]
priority: 8
tags: [tutorial, movement, navigation, analytical]
notes: >
Detective version of the 'check the signage' tutorial. Colon usage for the signage
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
framing; he reads the environment through his professional lens first. 'Worth learning
before the district learns me' — dry awareness of his asymmetric visibility in this
community. Same teaching as the smuggler version, different cognitive frame.
- id: pc-detective_m_d_tut_002
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
role: player_character
access: [public]
trust: surface
situation: [arrival, investigation]
trigger: first_new_section
mood: [focused]
priority: 6
tags: [tutorial, movement, navigation, analytical]
notes: >
Fires on entering a new section for the first time. 'Commission briefing had the
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
principle: documentation is a starting point, not a substitute for observation.
Teaches: the district has distinct sections to explore.
# -----------------------------------------------------------------------
# FOG OF PERCEPTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_003
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_encounter
mood: [focused]
priority: 8
tags: [tutorial, fog, perception, analytical]
notes: >
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
the detective categorizes it as an information state, not a barrier. Compare to
smuggler's 'Might be worth checking' — same fog, different response.
- id: pc-detective_m_d_tut_004
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_fog_edge
mood: [focused]
priority: 6
tags: [tutorial, fog, perception, analytical]
notes: >
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
procedure' — detective frames the limitation as methodology. The second clause
('not a limitation, a working condition') is self-instruction: he's telling himself
not to treat fog as frustrating but as standard operating environment. Teaches:
perception range is persistent throughout gameplay, and the correct response is
movement, not waiting.
# -----------------------------------------------------------------------
# SOUND MODEL
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_005
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_sound_heard
mood: [focused]
priority: 8
tags: [tutorial, sound, perception, analytical]
notes: >
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
and what he can't (count, content). No contractions. Teaches: sound channel is active
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
reality, systematized differently.
- id: pc-detective_m_d_tut_006
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
role: player_character
access: [public]
trust: surface
situation: [observation]
trigger: first_off_screen_sound
mood: [focused]
priority: 6
tags: [tutorial, sound, perception, analytical]
notes: >
Second sound tutorial — fires on an off-screen audio source. The detective parses
the sound through multiple analytical dimensions: surface type, count, direction,
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
signature' is technical terminology applied to diegetic audio interpretation.
Teaches: sound carries more than presence — floor type, distance, count, direction
are all extractable. The detective extracts all of them automatically.
# -----------------------------------------------------------------------
# NPC INTERACTION
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_007
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
role: player_character
access: [public]
trust: surface
situation: [social, observation]
trigger: first_npc_proximity
mood: [focused]
priority: 8
tags: [tutorial, npc, interaction, analytical]
notes: >
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
his categorization (Commission-trained: everyone is a witness category until proven
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
is the investigative principle: don't prime witnesses, let them self-select information.
No contractions throughout. Teaches: NPC interaction is player-initiated and
approach-sensitive. Different method than the smuggler ('give them reason to') —
same outcome, different social model.
- id: pc-detective_m_d_tut_008
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
role: player_character
access: [public]
trust: surface
situation: [social, investigation]
trigger: first_npc_face_read
mood: [focused]
priority: 6
tags: [tutorial, npc, observation, analytical, authority]
notes: >
Fires when the player observes an NPC at close range. The detective's asymmetry:
his authority access tier makes him visible in ways the smuggler is not. NPCs react
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
the detective's Commission credentials affect NPC behavior, and that behavioral
response is data.
# -----------------------------------------------------------------------
# INSERT / HUD
# -----------------------------------------------------------------------
- id: pc-detective_m_d_tut_009
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
role: player_character
access: [public]
trust: surface
situation: [routine]
trigger: first_insert_open
mood: [focused]
priority: 8
tags: [tutorial, insert, hud, analytical]
notes: >
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
linked' — institutional framing, establishes why the detective's HUD is denser than
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
show the time and nearby contacts' — same overlay, presented as procedural requirement
vs. operational check.
- id: pc-detective_m_d_tut_010
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
role: player_character
access: [public]
trust: surface
situation: [investigation, routine]
trigger: first_insert_contact_flag
mood: [focused]
priority: 6
tags: [tutorial, insert, hud, lattice, investigation]
notes: >
Fires when the insert first populates with flagged entities. Count given precisely
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
insert is investigation-keyed, matching observed faces against case file entries.
This is why his overlay is analytically denser than the smuggler's.
@@ -0,0 +1,228 @@
character: smuggler
location: contradiction
lines:
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
#
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
#
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
# does not fire for this arc.
#
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
# gut reaction, not analytical procedure.
#
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
# shortened interactions, early exits from break room.
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
#
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
# fires once per KG state change), not by a cooldown value.
#
# Note: contradiction_detected is a new trigger type — requires server-side implementation
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
# -----------------------------------------------------------------------
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_001
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
- id: pc-smuggler_m_s_con_002
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational]
notes: >
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
recognition first, then the wrong details fall into place. Each fragment adds one layer
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
expectation, which is now violated. No contraction in the closing declarative (emphasis).
- id: pc-smuggler_m_s_con_003
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2]
notes: >
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
Epistemic containment in plain language.
# -----------------------------------------------------------------------
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_004
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 7
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
notes: >
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
gives Kael benefit of the doubt, then immediately walks it back with operational
logic. 'There's always a reason' is the street-smart equivalent of epistemic
containment — she's seen strange things before. The closing line is where the alarm
lives: this isn't strange, it's a protocol violation.
Lower priority (7) ensures primary lines fire first; server team should implement
delay_after_trigger_ticks for proper sequencing.
- id: pc-smuggler_m_s_con_005
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
role: player_character
access: [public]
trust: surface
situation: [investigation, alone]
trigger: contradiction_detected
mood: [focused]
priority: 6
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
notes: >
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
understatement. Operational logic applied to an emotional moment: the smuggler
keeps the processing clean even when the stakes are personal.
# -----------------------------------------------------------------------
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
# -----------------------------------------------------------------------
- id: pc-smuggler_m_s_con_006
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused, anxious]
priority: 10
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
notes: >
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
catalog builds the picture: three days of signals, then this. Closing line names the
conclusion without naming its implication: 'working an angle' is street-smart language
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
she doesn't know that yet.
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
or simplify to a different check.
- id: pc-smuggler_m_s_con_007
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [focused]
priority: 9
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, operational]
notes: >
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
means either Devra is running something Kael's involved in (plausible, alarming) or
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
good' — the smuggler's understated close for a significant threat read.
- id: pc-smuggler_m_s_con_008
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
role: player_character
access: [public]
trust: surface
situation: [investigation, observation]
trigger: contradiction_detected
mood: [anxious]
priority: 8
prerequisites:
entities:
- id: "npc:kael-davan"
state: Contradicted
tells_observed: 2
tags: [contradiction, friend-arc, kael, phase-3, intuition]
notes: >
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
describes the behavioral changes without cataloging them. 'Now I know why' — the click
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
out' is street-smart language for exit attempt: the smuggler doesn't know the details
but the read is right. This line is the closest the smuggler comes to naming what's
happening — and she still doesn't say it explicitly.
@@ -1,7 +1,7 @@
character: smuggler
location: general
lines:
- id: general_m_s_001
- id: pc-smuggler_m_s_001
text: "Another day on Sova. Same air, same hum, same faces."
role: player_character
access: [public]
@@ -10,7 +10,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: general_m_s_002
- id: pc-smuggler_m_s_002
text: "The span gate hum's louder today. Maintenance hasn't touched it in weeks."
role: player_character
access: [public]
@@ -19,7 +19,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: general_m_s_003
- id: pc-smuggler_m_s_003
text: "Twenty minutes between shifts. That's the window. Always has been."
role: player_character
access: [public]
@@ -28,7 +28,7 @@ lines:
trigger: time_idle
tags: [operational, orientation]
- id: general_m_s_004
- id: pc-smuggler_m_s_004
text: "People talk about leaving Sova. Nobody actually does."
role: player_character
access: [public]
@@ -37,7 +37,7 @@ lines:
trigger: time_idle
tags: [reflection]
- id: general_m_s_005
- id: pc-smuggler_m_s_005
text: "Two years of this. Still not used to the gate vibration in my teeth."
role: player_character
access: [public]
@@ -46,7 +46,7 @@ lines:
trigger: hear_sound
tags: [environmental, personal]
- id: general_m_s_006
- id: pc-smuggler_m_s_006
text: "The money's good. The risk's manageable. Keep telling yourself that."
role: player_character
access: [public]
@@ -55,7 +55,7 @@ lines:
trigger: time_idle
tags: [reflection, operational]
- id: general_m_s_007
- id: pc-smuggler_m_s_007
text: "Back again. Same corridors, same shortcuts, same reasons."
role: player_character
access: [public]
@@ -64,7 +64,7 @@ lines:
trigger: return_visit
tags: [arrival]
- id: general_m_s_008
- id: pc-smuggler_m_s_008
text: "Commission patrols are thinner this cycle. Good or bad — hard to say."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
trigger: observe_anomaly
tags: [operational, caution]
- id: general_m_s_009
- id: pc-smuggler_m_s_009
text: "If anyone asks, I'm clocking overtime. That's always the story."
role: player_character
access: [public]
@@ -82,7 +82,7 @@ lines:
trigger: time_idle
tags: [operational, cover]
- id: general_m_s_010
- id: pc-smuggler_m_s_010
text: "Recycled air tastes different near the outer ring. Sharper. More machine."
role: player_character
access: [public]
@@ -3,7 +3,7 @@ location: maintenance-corridors
lines:
# --- Arrival + Environmental Flavor ---
- id: maintenance-corridors_m_s_001
- id: pc-smuggler_m_s_001
text: "Service corridor. Dim lights, damp walls, and the constant drip of condensation."
role: player_character
access: [public]
@@ -12,7 +12,7 @@ lines:
trigger: enter_location
tags: [arrival, atmospheric]
- id: maintenance-corridors_m_s_002
- id: pc-smuggler_m_s_002
text: "Cooler down here. The ventilation doesn't reach this far."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
trigger: enter_location
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_s_003
- id: pc-smuggler_m_s_003
text: "The access panel's still loose. Good — our route's intact."
role: player_character
access: [public]
@@ -30,7 +30,7 @@ lines:
trigger: enter_location
tags: [arrival, operational]
- id: maintenance-corridors_m_s_004
- id: pc-smuggler_m_s_004
text: "Pipes groaning overhead. Station's old. These corridors are older."
role: player_character
access: [public]
@@ -39,7 +39,7 @@ lines:
trigger: hear_sound
tags: [environmental, atmospheric]
- id: maintenance-corridors_m_s_005
- id: pc-smuggler_m_s_005
text: "Water recycling rumbles through the walls down here. Constant."
role: player_character
access: [public]
@@ -48,7 +48,7 @@ lines:
trigger: hear_sound
tags: [environmental]
- id: maintenance-corridors_m_s_006
- id: pc-smuggler_m_s_006
text: "Back in the maintenance tunnels. Rust and coolant."
role: player_character
access: [public]
@@ -57,7 +57,7 @@ lines:
trigger: return_visit
tags: [arrival, atmospheric]
- id: maintenance-corridors_m_s_007
- id: pc-smuggler_m_s_007
text: "Emergency lighting only. Just enough to see where you're going."
role: player_character
access: [public]
@@ -66,7 +66,7 @@ lines:
trigger: enter_location
tags: [environmental]
- id: maintenance-corridors_m_s_008
- id: pc-smuggler_m_s_008
text: "Footsteps echo different down here. You learn to listen."
role: player_character
access: [public]
@@ -77,7 +77,7 @@ lines:
# --- NPC + Operational ---
- id: maintenance-corridors_m_s_009
- id: pc-smuggler_m_s_009
text: "Renn's chalk marks on the wall. Route's been used recently."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
trigger: observe_anomaly
tags: [operational, renn]
- id: maintenance-corridors_m_s_010
- id: pc-smuggler_m_s_010
text: "Someone left a crate at junction B-4. Not one of ours."
role: player_character
access: [public]
@@ -95,7 +95,7 @@ lines:
trigger: observe_anomaly
tags: [operational, caution]
- id: maintenance-corridors_m_s_011
- id: pc-smuggler_m_s_011
text: "The temp storage hatch is warm. Cargo cycled through recently."
role: player_character
access: [public]
@@ -104,7 +104,7 @@ lines:
trigger: enter_location
tags: [operational]
- id: maintenance-corridors_m_s_012
- id: pc-smuggler_m_s_012
text: "Scratches on the lock plate. Someone's been forcing this access point."
role: player_character
access: [public]
@@ -113,7 +113,7 @@ lines:
trigger: observe_anomaly
tags: [environmental, caution]
- id: maintenance-corridors_m_s_013
- id: pc-smuggler_m_s_013
text: "Timer says twelve minutes until the oversight gap closes. Plenty."
role: player_character
access: [public]
@@ -122,7 +122,7 @@ lines:
trigger: time_idle
tags: [operational]
- id: maintenance-corridors_m_s_014
- id: pc-smuggler_m_s_014
text: "That's Renn's jacket on the pipe junction. He was here."
role: player_character
access: [public]
@@ -131,7 +131,7 @@ lines:
trigger: observe_anomaly
tags: [npc, renn, operational]
- id: maintenance-corridors_m_s_015
- id: pc-smuggler_m_s_015
text: "Voices echoing from the junction. Can't make out words yet."
role: player_character
access: [public]
@@ -140,7 +140,7 @@ lines:
trigger: hear_sound
tags: [caution]
- id: maintenance-corridors_m_s_016
- id: pc-smuggler_m_s_016
text: "The route through B-section is clear. Voss kept his word."
role: player_character
access: [public]
@@ -151,7 +151,7 @@ lines:
# --- Knowledge-Gated Lines ---
- id: maintenance-corridors_m_s_017
- id: pc-smuggler_m_s_017
text: "Corridor B-7. This is where I saw Kael. With someone I don't know."
role: player_character
access: [public]
@@ -165,7 +165,7 @@ lines:
priority: 8
tags: [npc, kael, contradiction, friend-arc]
- id: maintenance-corridors_m_s_018
- id: pc-smuggler_m_s_018
text: "Fresh boot prints in the dust. Two sets. Different sizes."
role: player_character
access: [public]
@@ -178,7 +178,7 @@ lines:
min_confidence: suspects
tags: [evidence, kael, friend-arc]
- id: maintenance-corridors_m_s_019
- id: pc-smuggler_m_s_019
text: "New scratch marks on the service hatch. Someone's using our route."
role: player_character
access: [public]
@@ -192,7 +192,7 @@ lines:
priority: 7
tags: [operational, caution]
- id: maintenance-corridors_m_s_020
- id: pc-smuggler_m_s_020
text: "The container in temp was supposed to move last shift. It didn't. Why?"
role: player_character
access: [public]
@@ -205,7 +205,7 @@ lines:
min_confidence: knows_of
tags: [operational, contraband]
- id: maintenance-corridors_m_s_021
- id: pc-smuggler_m_s_021
text: "Camera's been repositioned at junction C-2. Who authorized that?"
role: player_character
access: [public]
@@ -219,7 +219,7 @@ lines:
priority: 7
tags: [investigation, caution]
- id: maintenance-corridors_m_s_022
- id: pc-smuggler_m_s_022
text: "If Kael's meeting people down here without telling us, the whole route's exposed."
role: player_character
access: [public]
@@ -233,7 +233,7 @@ lines:
priority: 8
tags: [npc, kael, operational, friend-arc]
- id: maintenance-corridors_m_s_023
- id: pc-smuggler_m_s_023
text: "Sealed containers don't end up in temp storage by accident. Someone rerouted."
role: player_character
access: [public]
@@ -246,7 +246,7 @@ lines:
min_confidence: knows_details
tags: [operational, contraband]
- id: maintenance-corridors_m_s_024
- id: pc-smuggler_m_s_024
text: "Kael's not answering his lattice. He was supposed to meet me here."
role: player_character
access: [public]
@@ -260,7 +260,7 @@ lines:
priority: 7
tags: [npc, kael, concern, friend-arc]
- id: maintenance-corridors_m_s_025
- id: pc-smuggler_m_s_025
text: "Someone wiped the dust off this hatch. Recently. Carefully."
role: player_character
access: [public]
@@ -8,7 +8,7 @@ lines:
# Arc: station hum → spatial count → fog boundary → thin crowd → Kael sighting ×2 → news ticker → sound ping → time countdown → settle ×2.
# All lines fully schema-compliant per D-035 (role, access, trust, situation present).
- id: opening_m_s_001
- id: pc-smuggler_m_s_001
text: "Station hum. B-flat, steady — freight's moving."
role: player_character
access: [public]
@@ -21,7 +21,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric]
notes: "Opening sensation. Station audio registers first — the hum tells her freight is running. Teaches: audio environment carries operational information (D-074)."
- id: opening_m_s_002
- id: pc-smuggler_m_s_002
text: "Sixteen bays, three arms running. Standard morning."
role: player_character
access: [public]
@@ -34,7 +34,7 @@ lines:
tags: [opening-hook, tutorial-arc, orientation]
notes: "Spatial orientation. She clocks the floor in two beats — count of bays, count of running arms. Everything in fragments. Teaches: the terminal layout."
- id: opening_m_s_003
- id: pc-smuggler_m_s_003
text: "Haze past bay eight today. Can't see the far dock from here."
role: player_character
access: [public]
@@ -47,7 +47,7 @@ lines:
tags: [opening-hook, tutorial-arc, fog, perception]
notes: "Fog boundary. She notices the edge of her vision as a fact of the floor, not a mechanic. Teaches: perception range has limits (D-015, D-046)."
- id: opening_m_s_004
- id: pc-smuggler_m_s_004
text: "Morning crew's spread thin. Good. Room to move."
role: player_character
access: [public]
@@ -60,7 +60,7 @@ lines:
tags: [opening-hook, tutorial-arc]
notes: "Light crowd = operational comfort. 'Good.' standalone verbal tic (section 1.7). Teaches: NPC density affects movement options."
- id: opening_m_s_005
- id: pc-smuggler_m_s_005
text: "Kael's at dock three. Right where he should be."
role: player_character
access: [public]
@@ -73,7 +73,7 @@ lines:
tags: [opening-hook, tutorial-arc, kael, friend-arc]
notes: "First Kael sighting. Warm, immediate — she locates him before she finishes reading the floor. Teaches: named NPCs are identifiable via insert overlay (D-033 green entity)."
- id: opening_m_s_006
- id: pc-smuggler_m_s_006
text: "Already has his clipboard. Day's organized before it starts. Two years, same routine."
role: player_character
access: [public]
@@ -86,7 +86,7 @@ lines:
tags: [opening-hook, tutorial-arc, kael, friend-arc]
notes: "Kael warmth through behavior-reading, not labeling. Three beats: observation → interpretation → familiarity. 'Two years, same routine' says she knows his patterns intimately. Establishes: Kael is trusted, competent, reliable — the baseline before the friend arc erodes it."
- id: opening_m_s_007
- id: pc-smuggler_m_s_007
text: "Bulletin board's got a new Commission notice. Lattice registration updates. Not today's problem."
role: player_character
access: [public]
@@ -99,7 +99,7 @@ lines:
tags: [opening-hook, tutorial-arc, news-ticker]
notes: "News ticker glance. She reads and dismisses it. The Commission exists as background noise — relevant only when it stops being ignorable. Teaches: environmental text exists, player can engage or skip. D-037 irony: lattice registration is exactly what the ring circumvents."
- id: opening_m_s_008
- id: pc-smuggler_m_s_008
text: "Span gate echo from dock fourteen. Something inbound, running heavy by the sound of it."
role: player_character
access: [public]
@@ -112,7 +112,7 @@ lines:
tags: [opening-hook, tutorial-arc, sound, perception]
notes: "First sound ping at the edge of range. She interprets pitch instantly — the echo tells her load weight. Teaches: sound carries operational information (D-018, D-074 sound model). Sound channel is meaningful."
- id: opening_m_s_009
- id: pc-smuggler_m_s_009
text: "Shift board says light cargo today. Fifty minutes before Voss does his rounds."
role: player_character
access: [public]
@@ -125,7 +125,7 @@ lines:
tags: [opening-hook, tutorial-arc, operational]
notes: "'Fifty minutes' = time-as-countdown verbal tic (section 1.7). She's already calculating windows. Teaches: time and NPC patrol rhythms are trackable."
- id: opening_m_s_010
- id: pc-smuggler_m_s_010
text: "Same floor, same faces, same hum. Could be worse."
role: player_character
access: [public]
@@ -138,7 +138,7 @@ lines:
tags: [opening-hook, tutorial-arc, atmospheric]
notes: "Settling line. Baseline contentment. Parallel three-beat structure then understatement. 'Could be worse' is her comfort language — not enthusiastic, just settled. Voice anchor for confidence register (section 1.8)."
- id: opening_m_s_011
- id: pc-smuggler_m_s_011
text: "Dock's clean this morning. Good start."
role: player_character
access: [public]

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