Compare commits

..
150 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 cc43f14c39 chore(meta): release v0.1.15
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:24:23 +01:00
jpmschweitzerandClaude Opus 4.6 0bf5616fcd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:56:20 +01:00
jpmschweitzerandClaude Opus 4.6 e1cbcc243b docs(sprints): add sprint 16 "Converse" briefings
Plan 8 tickets across server (4), client (2), copy (1), visual (1).
Focus: two-way dialogue exchange, trust-gated gossip, sprite pipeline,
and housekeeping fixes. No carry-overs from sprint 15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:56:10 +01:00
jpmschweitzerandClaude Opus 4.6 07199c8ddc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:45 +01:00
jpmschweitzerandClaude Opus 4.6 b97f65e8ab docs(decisions): D-035 amendment — NPC-scoped line ID namespace
Sprint 15 amendment: line ID namespace changed from location-scoped
to NPC-scoped. Each NPC's IDs are independent, eliminating cross-file
coordination and collision risk at scale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:29 +01:00
jpmschweitzerandClaude Opus 4.6 fe71b11315 chore(skills): update pr-review to use worktree paths for all agents
All reviewer agents now read source files from worktree paths using
the Read tool instead of git show. Removes Bash dependency for
non-code reviewers and ensures files are always the actual branch
checkout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:24 +01:00
jpmschweitzerandClaude Opus 4.6 0880e8d987 feat(client): permanent dialogue panel + protocol v13 + build check
- dialogue_box.gd: panel is always visible as permanent insert UI
  element per D-061 — content fades but frame stays on screen
- protocol.gd: bump PROTOCOL_VERSION to 13
- Makefile: add check-protocol target that verifies server/client
  protocol versions match, runs automatically before build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:18 +01:00
jpmschweitzerandClaude Opus 4.6 eebc151b9f docs(briefings): add diagram responsibilities to Qatux and project docs
- CLAUDE.md: add docs/diagrams/ to project structure, add diagram
  file conventions for D-record changes
- qatux.md: add priority 5 for diagram creation/updates
- workshop-start SKILL.md: add diagram step in wrap-up sequence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:12 +01:00
jpmschweitzerandClaude Opus 4.6 996a000146 docs(ui): add 19 UI wireframes across 5 categories (v0.1 + v1.0)
First wireframe set covering all major UI surfaces:
- HUD: basic insert (v0.1), full insert with perception modes (v1.0)
- Dialogue: speaking, choosing, overheard states (v0.1), full with
  relationship indicators (v1.0)
- Monologue: idle, reaction (v0.1), thought threading (v1.0)
- Popups: modal, tooltip, interaction list (v0.1), context menu,
  notification stack (v1.0)
- Menus: main, pause, save/load (v0.1), main menu, options (v1.0)

README.md cross-references each wireframe to governing D-records.
JSON source + PNG exports tracked in git.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:06 +01:00
jpmschweitzerandClaude Opus 4.6 30a21e9a08 chore(skills): rewrite frame0-wireframe skill with JSON sync workflow
Replace individual API call workflow with JSON-as-truth architecture.
Local wireframe JSON files are the source of truth; Frame0 is treated
purely as a renderer. New frame0-sync.py handles push/pull/export with
stable local ID <-> ephemeral Frame0 ID mapping via .idmap.json files.

Key changes:
- frame0-sync.py: push/pull/export with topo-sorted shape creation
- frame0-export-batch.sh: batch export with staleness check
- frame0-cmd.sh: fixed command names, list-pages JSON bug, added commands
- Removed frame0-wireframe.sh (superseded by JSON workflow)
- SKILL.md: renderer-only guidance, simplified workflow
- api-reference.md: corrected commands, type/color token mapping
- component-library.md: all 6 patterns rewritten as JSON templates
- .gitignore: added *.idmap.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:35:57 +01:00
jpmschweitzerandClaude Opus 4.6 18b852fae6 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:57:20 +01:00
jpmschweitzerandClaude Opus 4.6 1e556ccca2 docs(diagrams): add decision diagrams across all domains
16 d2 diagrams covering architecture (IPC bridge, system tiers,
testability, audio bus), data flow (perception, dialogue, eavesdrop
pipelines), entity (knowledge graph, NPC generation, dialogue tags),
state (simulation tiers, movement stances, knowledge confidence), and
UI (HUD layout, interaction flow, fog layers). PNG output for universal
viewer compatibility. Removes test SVG from font debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:57:07 +01:00
jpmschweitzerandClaude Opus 4.6 08dfadb863 chore(skills): add frame0-wireframe skill for UI wireframing
Replaces Frame0 MCP server with portable bash+curl scripts that call
Frame0's local HTTP API directly. Includes low-level API wrapper,
high-level composition helpers with project styling, component library
patterns, and setup guide. Requires Frame0 desktop app running locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:57:00 +01:00
jpmschweitzerandClaude Opus 4.6 1d4b9cf299 chore(skills): add d2-diagram skill for text-to-diagram generation
Wraps d2 CLI (v0.7.1) with project defaults: theme 200 (Dark Mauve),
dagre layout, PNG output. Includes render/validate/batch scripts,
syntax guide, and five category templates (architecture, entity,
data-flow, state, UI flow).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:56:53 +01:00
jpmschweitzerandClaude Opus 4.6 d643ab7322 test: SVG with stripped embedded fonts for Gitea inline render check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:41:53 +01:00
jpmschweitzer 4d7d77dc3a merge: resolve CHANGELOG.md conflict from server branch 2026-02-21 15:13:29 +01:00
jpmschweitzer 687beba3ce Merge remote-tracking branch 'origin/copy' 2026-02-21 15:13:10 +01:00
jpmschweitzerandClaude Opus 4.6 e166409674 fix(copy): PR #53 review round 3 — span gate terminology, mood tags, em-dash consistency
Fix "span gate threshold" → "span gate hum" (D-036 canonical term) in
detective opening, add missing mood tags on opening_m_d_002 and
the-terminal_d_020, normalize sera-venn double-dashes to em-dashes,
fix situations.yaml header count, document greeting fallback behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:03:06 +01:00
jpmschweitzerandClaude Opus 4.6 67cadddaf2 fix(simulation): address PR #55 review — stale comment, range, duplication, docs
- Fix protocol version comment (12 → 13) in ObserverSnapshot doc
- Widen Want intensity range from 3..=9 to 1..=10 to match spec and test
- Replace duplicated pool selection in trigger_recognition_monologue with
  call to select_pool_line helper (~50 lines removed)
- Document NaiveSpatialIndex migration cost for grid/quadtree swap
- Remove misleading Default derive from TellCategory (Nervous is not a
  sensible default for neutral NPCs)
- Add caller invariant doc on generate_npc (no TilePosition spawned)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:55:21 +01:00
jpmschweitzerandClaude Opus 4.6 4be9cde3fa fix(copy): PR #53 review round 2 — ID collision, wary/tired moods, greeting enum
- Renumber Kael greeting IDs _d_046-049 → _d_050-053 (pc-detective.yaml holds
  046-048, Hoshe R2-H1)
- Replace pre-existing invalid mood values: wary→suspicious (22 lines),
  tired→frustrated (4 lines) across 5 dialogue files (Hoshe R2-H2)
- Add greeting to situations.yaml enum with server-pending note (Hoshe R2-H3)
- Fix double-dash → em-dash on Kael greeting lines (Hoshe R2-H5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:48:03 +01:00
jpmschweitzerandClaude Opus 4.6 3f799ad8ba docs(decisions): update D-076 dialogue max-width from 640px to 1200px
Reflects the intentional override — 1200px chosen for readability
after playtest feedback. Updates decision text, derivation, and
amendment history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:46:02 +01:00
jpmschweitzerandClaude Opus 4.6 062ae88803 fix(client): revert DIALOGUE_MAX_WIDTH to 1200px (intentional override of D-076)
The 1200px value was a deliberate readability decision, not a spec
violation. Reverts the incorrect 640px change from round 1. Updates
comment and regression test to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:44:34 +01:00
jpmschweitzerandClaude Opus 4.6 78c8bb97de fix(client): address PR #54 round 2 — stale comments, D-076 test, GROUND_FLOOR const
Remove stale smoothing re-enable comments from main.gd (Hoshe #1).
Add DIALOGUE_MAX_WIDTH=640 regression test (Hoshe #2).
Extract GROUND_FLOOR const in tile_renderer (Tyre #3).
Clean up entity_renderer migration comment (Hoshe #3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:42:25 +01:00
jpmschweitzerandClaude Opus 4.6 c0fb316a0a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:40:38 +01:00
jpmschweitzerandClaude Opus 4.6 46cf744e6a data(client): update msgpack fixtures and golden files for protocol v13
Regenerate all msgpack test fixtures with tell_state and follow_state
fields. Update golden proof_room snapshot. Adjust serialization and
bridge tests for new ObserverSnapshot fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:40:18 +01:00
jpmschweitzerandClaude Opus 4.6 8538f916af feat(simulation): wire Sprint 15 systems + protocol v13
Register all new systems in NpcPlugin and SimulationPlugin with correct
ordering constraints. Protocol bumped to v13: tell_state on VisibleEntity,
follow_state and PostConversationQueue on ObserverSnapshot, Follow verb
on VerbKind. Observer snapshot populates tell state from DerivedTellState
and follow state from FollowTarget. System ordering: tolerance after
mood, deviation after activity, tell after mood+deviation, follow after
visibility geometry, event monologue after conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:40:09 +01:00
jpmschweitzerandClaude Opus 4.6 517318c8a7 feat(simulation): extend monologue event generation with new triggers (#119)
Add observe_npc, hear_sound, observe_anomaly, witness_interaction, and
post_conversation triggers to monologue system. trigger_event_monologue
system fires after sound collection, NPC conversations, and walk-away.
Context tags (location, situation, character_state) populated for D-035
content pool matching. COOLDOWN_TICKS=300 anti-spam guard respected.
witness_interaction fires after overheard NPC-to-NPC conversation per
D-078. Voice sounds and ambient sounds correctly excluded from
hear_sound trigger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:58 +01:00
jpmschweitzerandClaude Opus 4.6 0af86b1f0b feat(simulation): add follow mechanic (#241)
Follow verb on interaction dispatcher. FollowTarget component tracks
target entity, proximity ticks, and LOS-lost ticks. update_follow_state
system: observation events fire at double frequency while following,
NPC suspicion increases via stress when player within 2 tiles for 60+
ticks (configurable). Follow ends on LOS lost timeout, suspicion
threshold crossed, or player issues different action. FollowStateWire
emitted in ObserverSnapshot for client HUD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:47 +01:00
jpmschweitzerandClaude Opus 4.6 f31be6f866 feat(simulation): add routine deviation detection (#243)
RoutineDeviationEvent emitted when NPC breaks daily routine: wrong
location for day phase or wrong activity at correct location.
CurrentlyDeviating marker prevents duplicate events per episode.
Respects pathfinding-in-progress (no false positives). Feeds
observation event generator for observe_anomaly monologue triggers.
Primary detective mechanic per D-027 criterion 4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:37 +01:00
jpmschweitzerandClaude Opus 4.6 029337a801 feat(simulation): add personality and tell system (#90)
TellCategory enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation)
with DerivedTellState component. derive_tell_state system runs after
update_mood and detect_routine_deviation. Tell state derived from NPC
axis values per D-024: Secret+low Tolerance→Nervous, low Contentment+
Hostile→Angry, high Contentment+Friendly→Friendly, high Secret→Guarded.
v0.1 renderer is monologue text, not visual animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:27 +01:00
jpmschweitzerandClaude Opus 4.6 78c43ab802 feat(simulation): add NPC generation pipeline (#92)
RoleDefinition struct + generate_npc() seeds all 10 D-024 axes via
SimRng for deterministic generation. Constraint validation: no duplicate
relationship targets, stress < threshold, one routine entry per phase,
no contradictory personality trait pairs. Spawns fully-populated NPC
entity with 2-3 personality traits for tell system (#90).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:17 +01:00
jpmschweitzerandClaude Opus 4.6 9a20e4dea9 feat(simulation): add tolerance threshold monitoring system (#105)
ToleranceBreachEvent emitted when NPC stress exceeds per-seed threshold.
ToleranceBreached marker prevents duplicate events per episode, cleared
on recovery. check_tolerance_threshold system runs after update_mood,
integrates with mood FSM to push toward Hostile/Anxious. Background-tier
NPCs excluded. Unblocks #250 (triangle escalation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:39:08 +01:00
jpmschweitzerandClaude Opus 4.6 836c8e98cd feat(simulation): add SpatialIndex trait with naive Vec implementation (#340)
Define SpatialIndex trait with entities_in_range, entities_at, and
update methods. NaiveSpatialIndex uses Vec backend with Manhattan
distance. sync_spatial_index system auto-updates from Changed<TilePosition>.
Registered as Bevy resource. Trait abstraction allows grid/quadtree
replacement later without touching callers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:38:59 +01:00
jpmschweitzerandClaude Opus 4.6 9e33a82e1d fix(copy): PR #53 review — ID collision, moods.yaml enum, voice/lore fixes
- Renumber Kael greeting variants _d_039-042 → _d_046-049 (collision with
  pc-smuggler.yaml, Hoshe #1)
- Update moods.yaml enum to Sprint 14 vocabulary (Paula #1, Miri #1)
- Add missing mood field on the-terminal_d_014 (Hoshe #3)
- Fix "wormhole threshold" → "span gate threshold" in detective opening (Miri #2)
- Fix "We've worked this district before" — detective is newly arrived (Miri #5)
- Fix smuggler opening "That's Kael" label → behavior-read (Paula #5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:22:34 +01:00
jpmschweitzerandClaude Opus 4.6 c347f28cbd fix(client): address PR #54 review — 7 items across Hoshe and Tyre
Critical: DIALOGUE_MAX_WIDTH 1200 → 640 to match D-076 spec.
tile_renderer: clarify z = server floor level, not scene z_index.
Add z-filter unit test (tiles at z!=0 must be skipped).
Camera test: is_equal → distance check for float safety, convergence
test frames 40 → 120 for robustness at lower smoothing speeds.
Teleport: remove redundant first snap in _teleport_transition (the
camera block in _process handles it via _teleport_in_progress flag).
entity_renderer: document y-sort bottom-anchor migration path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:20:27 +01:00
jpmschweitzerandClaude Opus 4.6 519483a0cc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:10:20 +01:00
jpmschweitzerandClaude Opus 4.6 e616af6eca test(client): sprint 15 validation tests — camera, UI framework, entities
Add test_smooth_camera_sprint15.gd (11 tests): lerp convergence,
teleport snap, D-015 fixed-north, constant range validation.
Add test_ui_framework_sprint15.gd (20 tests): D-049 z-layer hierarchy,
OQ-07 insert_active, #241 follow stub, Sprint 14 regression checks.
Update test_camera_anchor.gd and test_client_p3.gd for ENTITY_OFFSET_X/Y
split and manual lerp camera behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:10:06 +01:00
jpmschweitzerandClaude Opus 4.6 176052dfd6 feat(client): manual exponential camera smoothing (#117)
Replace Godot built-in Camera2D smoothing with manual lerp using
CAMERA_SMOOTHING_SPEED (8.0) in constants.gd — same exponential
smoothing pattern as entity_renderer.gd. Teleport snap preserved
via _teleport_in_progress flag. D-015 fixed-north camera lock intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:09:58 +01:00
jpmschweitzerandClaude Opus 4.6 7ddee15f06 feat(client): tilemap z-filtering, entity 24x32 footprint, follow stub (#71, #72)
tile_renderer: only render z=0 tiles on FloorTiles layer (D-049 z-stack).
entity_renderer: fix footprint from 24x24 to 24x32 per D-044, split
ENTITY_SIZE into ENTITY_WIDTH/ENTITY_HEIGHT with separate offsets.
game_state: add follow_target_id stub for server ticket #241.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:09:53 +01:00
jpmschweitzerandClaude Opus 4.6 378c4945e7 feat(copy): #170 Layer 2 relationship history — greeting variants + situation mapping
New: docs/design/layer2-situation-mapping.md — full InteractionMemory→Layer 2
pipeline documentation, 5 relationship history states, authoring checklist.

Greeting variants added:
- Kael Davan: 12 lines (4 per location × 3 locations)
- Sera Venn: 4 lines at The Last Shift
All use situation: [greeting], correct Sprint 14 mood vocabulary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:05:40 +01:00
jpmschweitzerandClaude Opus 4.6 458e50eee0 fix(copy): #169 access tier audit — mood vocab rename + monologue structural tags
Part A: Sprint 14 mood vocabulary applied across 20 dialogue files (334 fixes):
  fond→warm, comfortable→content, worried→anxious, concerned→frustrated,
  analytical→focused, conflicted→anxious/suspicious per context.

Part B: All 12 monologue pool files now carry required D-035 structural tags
  (role, access, trust, situation) — 223 lines fixed.

Part C: Full access tag audit passed — all dialogue lines have valid tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:05:29 +01:00
jpmschweitzerandClaude Opus 4.6 6dc3a123e6 feat(copy): #299 #300 opening hook monologue — smuggler + detective first 5 minutes
Smuggler: 11-line diegetic tutorial arc (station hum → Kael → fog → sound ping)
Detective: 12-line analytical tutorial arc (lattice overlay → Sera → manifest anomaly)

Both sequences: priority 8-10, cooldown 9999 (fire once), prerequisites null,
fully D-035 schema-compliant. Voice-checked against docs/design/voice-patterns.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:05:17 +01:00
jpmschweitzer eb6b5cc337 Merge remote-tracking branch 'origin/main' into client
# Conflicts:
#	client/data/dialogue-theme.yaml
#	client/scripts/main.gd
#	client/scripts/protocol/protocol.gd
#	client/scripts/ui/debug_overlay.gd
#	client/ui/dialogue_box.gd
2026-02-21 13:48:08 +01:00
jpmschweitzerandClaude Opus 4.6 c01ad011ed docs(sprints): add Sprint 15 React briefings
Server (7), client (5), copy (2) tickets. Goal: player acts and
world reacts — follow/examine, dialogue access layers, server-side
monologue events, personality and tell system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:49 +01:00
jpmschweitzerandClaude Opus 4.6 aa97ef988a chore(client): track Godot-generated uid and import files
New .uid files from Godot 4 and updated .import metadata for
audio assets and fonts added during Sprint 14.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:43 +01:00
jpmschweitzerandClaude Opus 4.6 e1e9f3479a chore(skills): add bug-report processing skill
Skill for triaging in-game bug reports from the Godot client's
user://bug-reports/ directory. Reads snapshot, render, inputs,
and description files; presents summaries; offers investigate,
create ticket, or dismiss actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:36 +01:00
jpmschweitzerandClaude Opus 4.6 136d21ac8b chore(config): specify --bin in Makefile cargo run targets
Disambiguates binary when multiple bin targets exist in the
server crate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:29 +01:00
jpmschweitzerandClaude Opus 4.6 038f97f07b data(client): update msgpack fixtures for protocol v12
Regenerated all cross-language test fixtures to include new
conversation_events wire fields (speaker/target name, color index).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:24 +01:00
jpmschweitzerandClaude Opus 4.6 2c69e557c2 fix(client): resolve WASD freezes, warnings, and chime spam
- bug_report_dialog: release_focus() before queue_free() prevents
  stale focus on ModalLayer blocking input to lower layers
- dialogue_box: clear dialogue_active immediately on conversation
  end instead of holding for entry_lifetime
- main.gd: permanent entity ID tracking per room stops recognition
  chime re-triggering when NPCs oscillate in/out of fog queue
- audio_manager: correct path res://audio/ to res://assets/audio/
- world_radial.tscn: anchors_preset 15 to 0 (script sets size)
- bug_report_dialog: push_warning to print (informational)
- debug_overlay: max() to maxi() for integer line count
- monologue_display: remove unnecessary Tween cast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:18 +01:00
jpmschweitzerandClaude Opus 4.6 03ff3c5fef feat(ui): entity-anchored dialogue log with keyboard selection
Log entries store entity IDs and resolve display names at render
time from _entity_display lookup — enables retroactive name update
when player learns an NPC's real name. Color index from server
replaces name-hash coloring for stable NPC colors.

Dialogue options: switched RichTextLabel to Label (fixes stacking
bug), added 1/2/3 number key selection, numbered option labels.

Interaction list: added background panel, mouse hover highlighting,
click-to-interact, pointing hand cursor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:43:08 +01:00
jpmschweitzerandClaude Opus 4.6 28e7e6d9ad feat(simulation): add NPC name masking with role labels and color index
Server-side infrastructure for information asymmetry in dialogue.
NPCs display role labels (Worker, Supervisor, Patron) instead of
real names until the player explicitly learns them via KG lookup.
NpcColorIndex (stable_id % 8) persists across name reveal.

ConversationEvent and DialogueResponseEvent carry display names +
color indices on the wire with serde(default) for compat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:42:59 +01:00
jpmschweitzerandClaude Opus 4.6 82e58559ba chore(meta): release v0.1.14
Sprint 14 "Live" complete — 22/22 tickets done. NPCs breathe
independently: mood, trust, routines, overheard conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:40:46 +01:00
jpmschweitzerandClaude Opus 4.6 e2985142f5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:56:13 +01:00
jpmschweitzerandClaude Opus 4.6 a0001c059a docs(scratchpad): note on contextual NPC conversation topics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:50 +01:00
jpmschweitzerandClaude Opus 4.6 9a91c18739 data(schema): raise monologue line maxLength from 160 to 256 chars
Hard cap at 256 with soft guidance to aim for ≤160 to avoid line wrap.
Allows longer lines for urgent monologue and confrontation beats that
need more space without schema validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:46 +01:00
jpmschweitzerandClaude Opus 4.6 536c7d953d docs(decisions): update D-061 with unified conversation log architecture
Sprint 14 #535 redesigned the dialogue box into a single chronological
log for both player-NPC and overheard NPC-NPC conversations. Updates
D-061 in perception.md to document the unified log pattern, entry
lifecycle, passive line treatment, and cross-references to D-076/D-078.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:55:40 +01:00
jpmschweitzerandClaude Opus 4.6 952f994d59 fix(ui): address PR #52 review — 10 items across Hoshe, Tyre, Araminta (#535)
Critical: bump PROTOCOL_VERSION 8→9 for conversation_events/ended fields.

Hoshe review:
- Dirty flag (_log_dirty) prevents per-frame O(n) BBCode rebuild
- BBCode injection: _escape_bbcode() replaces [ with [lb] on server text
- D-064 regression: dialogue_active cleared in fade callback, not before
- YAML quoting: remove unnecessary quotes from numeric values

Tyre review:
- Carry-forward for dialogue_response, conversation_events, conversation_ended
  in receive_bytes() — arrays merge, scalar falls through
- pause_requested/unpause_requested signals route through main.gd input
  recording (_pending_record_inputs) for #507 replay determinism
- Fix version comments: dialogue_response is v8 (#305), not v9
- Remove dead _active_overheard dictionary

Araminta review:
- Passive lines: ┃ glyph prefix + _desaturate() for name colours
- Active conversation entries pinned (no timeout), unpinned with timestamp
  reset on conversation end
- _enforce_contrast(): minimum luminance floor for name colour readability
- Simplified 1-on-1 attribution: "Speaker:" instead of "Speaker → You:"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzerandClaude Opus 4.6 2edc7c3098 feat(ui): unified dialogue log with overheard NPC conversations (#535)
Refactors dialogue box into a scrolling conversation log. All dialogue
(player-NPC and overheard NPC-NPC) flows chronologically, oldest at top.
Player response options at the bottom during active conversations.

- Entries expire after configurable timeout (equal for all message types)
- Walk-away clears options but preserves log entries (fair information)
- Per-character name colors from dialogue-theme.yaml (hash-indexed palette)
- Overheard lines render at 90% opacity (D-078)
- Protocol decode for conversation_events + conversation_ended
- GameState fields for conversation_events, conversation_ended, dialogue_response
- Mock Mira/Soren NPC-NPC conversation in test snapshot
- Also wires #511 debug overlay into main.gd and main.tscn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzerandClaude Opus 4.6 ef3f4a70fa feat(ui): F3 debug overlay — real-time game state display (#511)
Toggle with F3. Shows tick, fps, position, facing, stance, zone,
entity/tile counts, interaction/recognition counts, dialogue status,
stationary ticks, insert state, time/tick_rate, mode, gauntlet.
Two-column layout, click-through, hidden by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:51:40 +01:00
jpmschweitzerandClaude Opus 4.6 e19887c5a9 fix(ui): address PR #52 review — 10 items across Hoshe, Tyre, Araminta (#535)
Critical: bump PROTOCOL_VERSION 8→9 for conversation_events/ended fields.

Hoshe review:
- Dirty flag (_log_dirty) prevents per-frame O(n) BBCode rebuild
- BBCode injection: _escape_bbcode() replaces [ with [lb] on server text
- D-064 regression: dialogue_active cleared in fade callback, not before
- YAML quoting: remove unnecessary quotes from numeric values

Tyre review:
- Carry-forward for dialogue_response, conversation_events, conversation_ended
  in receive_bytes() — arrays merge, scalar falls through
- pause_requested/unpause_requested signals route through main.gd input
  recording (_pending_record_inputs) for #507 replay determinism
- Fix version comments: dialogue_response is v8 (#305), not v9
- Remove dead _active_overheard dictionary

Araminta review:
- Passive lines: ┃ glyph prefix + _desaturate() for name colours
- Active conversation entries pinned (no timeout), unpinned with timestamp
  reset on conversation end
- _enforce_contrast(): minimum luminance floor for name colour readability
- Simplified 1-on-1 attribution: "Speaker:" instead of "Speaker → You:"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:39:12 +01:00
jpmschweitzerandClaude Opus 4.6 1d0ba5f7d8 feat(ui): unified dialogue log with overheard NPC conversations (#535)
Refactors dialogue box into a scrolling conversation log. All dialogue
(player-NPC and overheard NPC-NPC) flows chronologically, oldest at top.
Player response options at the bottom during active conversations.

- Entries expire after configurable timeout (equal for all message types)
- Walk-away clears options but preserves log entries (fair information)
- Per-character name colors from dialogue-theme.yaml (hash-indexed palette)
- Overheard lines render at 90% opacity (D-078)
- Protocol decode for conversation_events + conversation_ended
- GameState fields for conversation_events, conversation_ended, dialogue_response
- Mock Mira/Soren NPC-NPC conversation in test snapshot
- Also wires #511 debug overlay into main.gd and main.tscn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:10:40 +01:00
jpmschweitzerandClaude Opus 4.6 9b53096b0b feat(ui): F3 debug overlay — real-time game state display (#511)
Toggle with F3. Shows tick, fps, position, facing, stance, zone,
entity/tile counts, interaction/recognition counts, dialogue status,
stationary ticks, insert state, time/tick_rate, mode, gauntlet.
Two-column layout, click-through, hidden by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:10:30 +01:00
jpmschweitzer 665f67626b Merge pull request 'feat(copy): Sprint 14 — monologue architecture, line pools, voice guide, schema, overheard dialogue' (#51) from copy into main 2026-02-20 19:31:28 +01:00
jpmschweitzerandClaude Opus 4.6 386d80df26 fix: resolve merge conflicts with main (Situation + Mood variants)
Keep all new variants from both branches: Greeting (copy), FirstMeeting
and RepeatedVisit (server). Combine Mood doc comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:31:20 +01:00
jpmschweitzer f1dcdc2ee9 Merge pull request 'feat(simulation): Sprint 14 — NPC mood, trust, routines, conversations, invariants' (#50) from server into main 2026-02-20 19:28:53 +01:00
jpmschweitzer c10b896562 Merge pull request 'feat(client): #122 monologue display — multi-line, priority queue, 34 tests' (#49) from client into main 2026-02-20 19:28:49 +01:00
jpmschweitzer b2559d5467 Merge pull request 'docs(visual): Sprint 14 visual spec suite — 6 design documents' (#48) from visual into main 2026-02-20 19:28:39 +01:00
jpmschweitzerandClaude Opus 4.6 f0e0673c99 fix(simulation): address PR #50 review — determinism, overflow, invariants
- Sort eligible NPCs by StableId for deterministic conversation pairing (D-010)
- Replace single_mut() with per-observer iteration (D-010 principle 3, D-027)
- Widen current_stress * 100 to i32 in mood derivation to prevent i16 overflow
- Promote line_interval to named constant LINE_INTERVAL_TICKS
- Add 4 Sprint 14 component invariants (MoodState, InteractionMemory,
  ActivityState+PathRequest exclusion, double-tagged tier detection)
- Attach MoodState to content spawn pipeline and gauntlet fixup
- VecDeque for InteractionMemory.notable_events (O(1) pop_front)
- Rename who_knows() to who_knows_full_scan() to communicate O(N) cost
- Add O(N^2) growth limit comment on conversation pair scan
- Document Suspicious/Focused as externally-set moods on enum variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:18:34 +01:00
jpmschweitzerandClaude Sonnet 4.6 677ca9b59d fix(ui): address PR #49 review — 6 warnings + 2 suggestions (#122)
Warnings fixed:
- BBCode injection (line 153): escape [ → [lb] in server text before interpolation
- sort_custom on silent-drop (line 125): sort now only runs on actual insertion/replacement
- clip_contents: add clip_contents=true to MonologueDisplay Control (overflow guard)
- confrontation tick guard (main.gd): _last_confrontation_tick deduplicates same-tick signals
- GameState decoupling: show_monologue() reads lattice_profile once and passes it through
  _show_line() → _build_line_node(); renderer no longer reaches into autoload (D-020)
- Equal-priority eviction: >= tiebreak (was >); FIFO for equal-priority queue overflow

Suggestions fixed:
- Minimum duration clamp: maxf(duration, FADE_IN_SEC + 0.1) — line survives own fade-in
- _label_text bounds check: guard against empty _visible before indexing [0]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:06:26 +01:00
jpmschweitzerandClaude Opus 4.6 3c4304f745 fix(visual): address PR #48 review comments
- sound-indicator-visual: D-069 → D-033/D-048 color reference, reconcile
  medium range S1 (3-8) vs S7 (3-20) contradiction, split dual values in
  quick-reference table
- entity-color-system: D-046 → D-015 for peripheral rendering, tighten
  cognitive delay wording, add accessibility ticket tracking note
- visual-grammar-v01: dialogue max-width corrected from ~70% to 640px
  per D-076
- the-friend-visual-treatment: D-063 citation title corrected
- tell-visual-expression: soften D-024 attribution (axis, not categories)
- environmental-text-standards: specify tile unit for terminal visibility
- monologue-display-spec: header updated to reflect Sprint 14 inclusion
- CHANGELOG: add entries for all 6 Sprint 14 design docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:04:00 +01:00
jpmschweitzerandClaude Opus 4.6 036e9dc5c6 fix(tests): update NPC profile count assertions (20→23) for Sprint 14 content
Three NPC profiles added by copy team content expansion. Updates
content_loading test expectations to match actual content directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:00:14 +01:00
jpmschweitzerandClaude Opus 4.6 91eb11da16 refactor(content): rename mood vocabulary to match voice guide (D-035 Sprint 14 amendment)
Schema and Rust Mood enum renamed for author-friendly vocabulary:
fond→warm, comfortable→content, worried→anxious, concerned→frustrated.
Dropped: analytical (merged into focused), conflicted (modeled as
suspicious+warm collision). Added: hostile. Final 8 moods: anxious,
frustrated, content, suspicious, warm, hostile, relieved, focused.
Neutral = untagged. Resolves Gestalt's blocking issue on #121.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:00:07 +01:00
jpmschweitzerandClaude Sonnet 4.6 019a7b2221 test(simulation): additional conversation system QA coverage (Hoshe, S14)
Additional integration tests for conversation cooldown application, distance
termination with both NPCs receiving cooldown, and edge cases in the NPC-to-NPC
conversation pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:40 +01:00
jpmschweitzerandClaude Sonnet 4.6 8bd65a5662 feat(simulation): NPC-NPC relationship dynamics with passive decay (#103, D-024)
update_relationship_dynamics runs once per game-minute (10 ticks). Edges
inactive for 1+ game-hours (600 ticks) decay trust 1 point toward 0 per
minute. Creates social texture: NPCs who haven't interacted drift to neutral
without active maintenance. Blocks #249 (social propagation, Sprint 15).

Constants: DECAY_INTERVAL_TICKS=10, DECAY_INACTIVITY_THRESHOLD_TICKS=600,
DECAY_DELTA=1. Integer arithmetic only (D-010 determinism).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:36 +01:00
jpmschweitzerandClaude Sonnet 4.6 bd60b356f8 feat(simulation): routine execution system with ActivityState (#101, D-031)
ActivityState component tracks the activity an NPC is currently performing
at their routine destination (activity name, phase, started_tick). The
enter_activity system runs after movement validation and sets ActivityState
when an NPC has arrived with no active path. Cleared on phase transitions.

Feeds TellTrigger::DuringActivity and D-028 Layer 2 situation matching.
Unit tests verified via shift_change room integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 dcd15a330f feat(simulation): interaction tracking + trust progression (#325, #324)
InteractionMemory component tracks interaction_count, last_interaction_tick,
and notable_events per NPC. Drives D-028 Layer 2 situation activation:
first_meeting (count==0) and repeated_visit (count>=3). warm_active in mood
system now derives from InteractionMemory within a 300-tick window.

Trust progression wired into dialogue systems: talk completion (+1),
walk-away (-1), confrontation (-2) emit TrustEvents consumed by update_trust.
InteractionEvent (WalkAway, Confrontation) recorded in notable_events for
fast per-pair access.

Adds FirstMeeting and RepeatedVisit Situation variants. 18 unit tests in
interaction.rs. All arithmetic integer-only (D-010 determinism). No HashMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:52:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 df4d7b4666 docs(design): add Gestalt KG validation to #253 prerequisite map and #121 mood guide
#253 monologue-content-architecture.md:
- All prerequisite fact_ids validated against knowledge vocabulary — APPROVED
- Flag: behavioral.sera_avoidance should be behavioral.sera_avoidance_pattern
- Flag: investigation.drin_inspection_pattern duplicated in investigation.yaml
- Confirm: cooldown is content-layer (schema field) — OQ item 3 resolved
- Pending: witness_interaction trigger payload (OQ item 4) — Dudley to confirm
  before named-pair witness_interaction lines can be authored

#121 monologue-voice-guide.md:
- BLOCKING: mood vocabulary mismatch — voice guide uses neutral/anxious/frustrated/
  content/warm/hostile but schema enum uses fond/comfortable/worried/analytical/
  conflicted/concerned/relieved; hostile not in schema at all
- Provide interim mapping table so Mellanie can use correct schema names
- Flag schema gaps: analytical and relieved have no voice guide coverage
- Recommend schema rename to voice guide names (Lead decision required)
- Mood-as-selection-weight model is mechanically coherent — APPROVED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:43:55 +01:00
jpmschweitzerandClaude Opus 4.6 c71ba8ebb3 test(ui): GameState integration + D-049 compliance tests for monologue display (#122)
7 tests: snapshot round-trip, null/stale clearing, non-dict rejection,
duration/id preservation, canvas layer constant. before_test/after_test
lifecycle hooks prevent state bleed between test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:43:41 +01:00
jpmschweitzerandClaude Sonnet 4.6 a14c980aad test(simulation): map-agnostic invariant tests, 36 invariants (#508)
Adds test_world::invariants with 29 world-query invariants (structural,
perception, population, simulation) and 7 system-execution tests. All
invariants are gated behind the gauntlet feature and run against the
fully-initialized gauntlet world. Documents StableId ranges through
Sprint 14 rooms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:56 +01:00
jpmschweitzerandClaude Sonnet 4.6 9eba259fd5 feat(simulation): NPC mood state machine (#323, D-024, D-035)
8-state NPC mood FSM driven by stress, time of day, and interaction events.
Adds MoodState component, derive_mood() pure function, and update_mood() Bevy
system wired into NpcPlugin. Adds Focused as 9th Mood content tag (D-035
Sprint 8 amendment). Syncs CurrentMood for Layer 4 dialogue selection.

31 unit tests covering priority ordering, boundary conditions, and system
integration. All random values route through SimRng (D-010 determinism).
No floats. No HashMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:51 +01:00
jpmschweitzerandClaude Sonnet 4.6 aa0d8ced37 feat(simulation): NPC-to-NPC conversation system (#247, D-078)
Implements server-authoritative per-word occlusion for NPC conversations.
ConversationEventBuffer drains into ObserverSnapshot each tick so the client
receives only words audible from the player's position. Updates test fixtures
to include the new conversation_events and conversation_ended fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:44 +01:00
jpmschweitzerandClaude Sonnet 4.6 b49d78ef6a fix(ui): guard empty text in show_monologue — no ghost slots or queue entries (#122)
show_monologue() now returns early when text.is_empty(), preventing:
- ghost visible slots with blank labels
- stagger timer advancing on empty calls
- empty strings queuing when slots are full

Tests: add test_show_monologue_with_empty_text_does_not_set_displaying and
two companion cases (stagger timer unchanged, no enqueue when full) anticipating
Hoshe's QA additions to the test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:41:22 +01:00
jpmschweitzerandClaude Sonnet 4.6 1765e2922f refactor(ui): monologue display — multi-line architecture per Tyre review (#122)
Rewrites the monologue display system per Tyre architecture review (Sprint 14):

Rendering:
- Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation)
- Lines created programmatically as MarginContainer > RichTextLabel per slot
- Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width
- Z-layer 7 in UILayer (CanvasLayer 20, D-049)

Queue:
- 5-entry priority queue; highest priority drains first
- On overflow: incoming line replaces lowest-priority queued entry if it outranks it
- Lower/equal priority incoming lines silently dropped when queue full

API:
  show_monologue(text, duration, priority=2, is_urgent=false)
- Replaces old (text, duration, character_type) signature
- main.gd passes priority and is_urgent from MonologueEvent fields
- Confrontation monologue: priority=3, is_urgent=true (D-063)

Colour:
- Reads GameState.lattice_profile at render time (D-032)
- lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8
- lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4
- Fallback for unknown profiles; no crash

Stagger: 0.15s between consecutive fade-ins (spec §5.4)
Opacity: standard 0.85, urgent 1.0; bloom deferred

GameState: adds lattice_profile field, parsed from snapshot

Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger,
no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:39:49 +01:00
jpmschweitzerandClaude Sonnet 4.6 0b5952a107 docs(visual): Sprint 14 visual spec suite — 6 design documents
Completes all Sprint 14 visual tickets (#304, #316, #317, #318, #334, #251).

- entity-color-system.md (#304): Full D-033 relationship→color spec. Hex values,
  runtime derivation from VisibleEntity.relationship, 0.5s transition behavior,
  fog edge cases, static object definition, color blindness assessment (deuteranopia
  and protanopia gaps flagged for v0.1.2+). Implementation notes for Stig.

- text-display-hierarchy.md (#316): Four content pipelines — dialogue, monologue,
  overheard/observation, environmental text. Screen positions, z-layers, font sizes,
  hex values, passive panel mode for D-078 overheard conversation.

- sound-indicator-visual.md (#317): Fog-edge pulse spec. Three color codes, direction
  encoding, three range-distance alpha levels, 2.5s pulse animation target, suppression
  rules, current vs target implementation delta for Stig.

- the-friend-visual-treatment.md (#318): Phase 1/2/3 visual states for Kael Davan
  and Sera Venn. Earned visual detail principle, 0.5s green→amber transition, staging
  requirement (first relationship color change in session), animation tier transition
  point. Cross-references entity-color-system.md.

- environmental-text-standards.md (#334): Signage, terminals, news tickers. Character
  limits, z-layers, two-state terminal model, bilingual Concordat Standard/Krenn
  vernacular treatment (D-036), scroll behavior, copy team and Stig quick-reference tables.

- tell-visual-expression.md (#251): 5 tell categories mapped to 6 Tier 2 behavioral
  patterns. Tile-level appearance descriptions, intensity scaling, server implementation
  notes for Dudley, monologue layer spec for Mellanie. THE FRIEND tell trajectory
  for Kael and Sera included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:38:45 +01:00
jpmschweitzerandClaude Sonnet 4.6 8c362c8535 feat(schema): implement D-035 converged tag taxonomy as canonical JSON Schema (#168)
- Add content/_schema/dialogue-line.schema.json — canonical standalone line schema
  with all 6 structural + 3 selection + 2 authoring-only tags and $defs for
  monologue extension (character, trigger, prerequisite)
- Update dialogue-pool.schema.json: add dual_lens and notes authoring-only tags,
  add additionalProperties: false to knowledge_grant
- Update monologue-pool.schema.json: add all 6 structural tags as required fields
  for schema compliance (role=player_character, access=[public], trust=surface are
  constrained constants); add topic, mood, dual_lens, notes
- Enumerate all 14 v0.1 situations and 9 v0.1 topics as allowed enum values per D-035
- Fix Rust sync gap: add Situation::Greeting and Mood::Focused to line_pool.rs
  (D-035 Sprint 8 amendments — were in schema but missing from Rust enums, causing
  content with these values to be silently dropped at runtime)
- Update line_preview.rs situation_str, mood_str match arms and help text for
  new variants; all 25 line_pool tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 18:38:37 +01:00
jpmschweitzerandClaude Sonnet 4.6 4365871846 feat(ui): monologue display — queue, character colours, italic BBCode (#122)
- Queue management: enqueue on mid-display arrival, drain in order, MAX_QUEUE_DEPTH=8 cap, no-overwrite contract (P0 #477)
- Character colours: detective=#c8e0ff (cool blue), smuggler=#f0c870 (warm amber), fallback to neutral insert text
- Typography: italic via BBCode [i] tags; font size 13px (smaller than dialogue)
- Positioning: bottom-left of viewport, 420×120px, 80px bottom margin above verb list
- main.gd: _get_active_character_type() reads player entity kind.data.character_type; passed to show_monologue() and _on_confrontation_monologue()
- gdUnit4 tests: queue order, depth cap, no-overwrite, BBCode output, colour mapping, fade timer integration

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:10:17 +01:00
jpmschweitzerandClaude Opus 4.6 1fa04c3e89 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:10:08 +01:00
jpmschweitzerandClaude Opus 4.6 2eb887c75f docs(visual): monologue display system visual spec (#315)
Typography, positioning, stacking rules (max 3 lines), priority
system (observation > atmosphere > tutorial), fade animation,
character-differentiated color (detective cool, smuggler warm),
and 80-character line length constraint for copy team.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All 548 tests pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:28:17 +01:00
356 changed files with 48690 additions and 745 deletions
+7 -1
View File
@@ -54,7 +54,13 @@
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(sprint-start)"
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
"Skill(pr-review)",
"Skill(ticket)",
"Skill(docs-search)",
"Skill(workshop-start)"
],
"deny": [
"Bash(git push --force *)",
+231
View File
@@ -0,0 +1,231 @@
---
name: bug-report
description: >
Process in-game bug reports captured by the Godot client's bug reporter.
Use when the user says "bug reports", "check bug reports", "process bugs",
or invokes /bug-report. Scans the user:// bug-reports directory, summarizes
each report, and offers investigation, ticket creation, or dismissal.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Write
---
# Bug Report Skill
Process in-game bug reports exported by the Godot client to the user data
directory. Each report is a directory containing a snapshot of game state at the
moment the tester filed the report.
```
BUG_REPORT_DIR: /var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/
```
## Report structure
Each report lives in a directory named `gauntlet-t{tick}-{timestamp}/` and
contains these files:
| File | Purpose |
|------|---------|
| `description.txt` | Tester notes + metadata (tick, room, stance, facing, position) |
| `render.txt` | Simplified text render of the game snapshot |
| `snapshot.json` | Full JSON snapshot (entities, dialogue state, etc.) |
| `inputs.jsonl` | Last 60 ticks of player input (replay format) |
| `snapshots.jsonl` | Last 60 ticks of observer snapshots |
| `seed.txt` | RNG seed for deterministic replay |
## Invocation
- `/bug-report` — scan and process all unprocessed reports
- `/bug-report <directory-name>` — process a specific report by directory name
## Workflow
### 1. Scan for unprocessed reports
List all report directories in the bug reports directory:
```bash
ls -1d "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/"*/
```
If no directories are found, report "No bug reports found." and stop.
If the user provided a specific directory name as argument, filter to only that
directory. If it does not exist, report the error and list available reports.
### 2. Read and summarize each report
For each report directory, read the following files using the Read tool:
1. **`description.txt`** — extract:
- Tester description / notes (free text at top)
- Tick number
- Room name
- Player stance, facing, position
2. **`render.txt`** — extract:
- A brief description of what the text render shows (room layout, visible
entities, player position marker)
3. **`snapshot.json`** — extract:
- Total entity count
- Whether dialogue is active (look for `dialogue` or `conversation` keys
with non-null/non-empty values)
- Whether monologue is active (look for `monologue` keys with non-null/
non-empty values)
- NPC names and positions if present
- Any error or anomaly fields
4. **`seed.txt`** — note the seed value for reference
Do NOT read `inputs.jsonl` or `snapshots.jsonl` during the summary phase.
These are large files reserved for the investigation step.
### 3. Present the summary list
Present a numbered list of all reports with their summaries. Format:
```
## Bug Reports Found: N
### 1. gauntlet-t{tick}-{timestamp}
- **Tick:** {tick} | **Room:** {room} | **Position:** ({x}, {y})
- **Stance:** {stance} | **Facing:** {facing}
- **Entities:** {count} | **Dialogue active:** yes/no | **Monologue active:** yes/no
- **Seed:** {seed}
- **Description:** {tester notes, first 2-3 lines}
- **Render overview:** {brief description of what render.txt shows}
- **Observations:** {any anomalies spotted in the snapshot}
### 2. gauntlet-t{tick}-{timestamp}
...
```
### 4. Offer actions per report
After presenting the summary list, ask the user which action to take for each
report. The three actions are:
#### Investigate
Dig deeper into the report for root cause analysis:
1. Read `snapshot.json` in full — analyze entity states, component values,
relationships between entities, any inconsistencies
2. Read `inputs.jsonl` — reconstruct what the player was doing in the 60 ticks
leading up to the report. Look for:
- Rapid input changes (stuck keys, input spam)
- Movement into walls or invalid positions
- Interaction attempts that may have failed
- Timing patterns (actions on same tick as state changes)
3. Read `snapshots.jsonl` — compare entity states across recent ticks to find
when the bug manifested:
- Entity position jumps
- State machine transitions that look wrong
- Component values going out of expected range
- Entities appearing or disappearing unexpectedly
4. Cross-reference with `render.txt` to confirm visual manifestation
5. Read `seed.txt` and note it — the seed plus `inputs.jsonl` should allow
deterministic replay of the scenario
Present findings as a root cause analysis:
```
## Investigation: gauntlet-t{tick}-{timestamp}
### Timeline
- t{tick-N}: {what happened}
- t{tick-M}: {state change}
- t{tick}: {bug manifests}
### Root cause
{Analysis of what went wrong and why}
### Affected systems
- {system 1}: {how it's involved}
- {system 2}: {how it's involved}
### Reproduction
Seed: {seed}
Replay inputs.jsonl from tick {start} to reproduce.
### Suggested fix
{If identifiable from the snapshot data}
```
After investigation, return to the action prompt for this report (the user
may want to create a ticket or dismiss after investigating).
#### Create ticket
Create a bug ticket in the project database. Determine the team from the
nature of the bug:
- **server** — simulation bugs (entity state, movement, AI, ECS systems,
perception, knowledge graph)
- **client** — rendering bugs (display glitches, UI issues, input handling,
audio, visual artifacts)
- **server,client** — integration bugs (protocol mismatch, desync, bridge
issues)
Construct the ticket title and description from the report summary and any
investigation findings. Use the ticket CLI:
```bash
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
```
The description should include:
- Bug summary (from tester notes)
- Tick, room, position
- Key observations from snapshot analysis
- Seed for reproduction
- Report directory name for reference
After creating the ticket, report the ticket ID to the user.
#### Dismiss
Mark the report as not actionable. Remove the report directory:
```bash
rm -rf "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/{report-dir}/"
```
**Always confirm with the user before deleting.** State clearly which directory
will be removed and wait for confirmation.
### 5. Batch processing
When processing multiple reports, work through them one at a time in the
numbered order presented. For each report, complete the chosen action before
moving to the next.
If the user wants to batch-dismiss multiple reports, confirm the full list
of directories that will be deleted before proceeding.
### 6. Final summary
After all reports have been processed, present a summary:
```
## Bug Report Processing Complete
- **Investigated:** {count}
- **Tickets created:** {count} ({ticket IDs})
- **Dismissed:** {count}
- **Remaining unprocessed:** {count}
```
## Tips
- Large `snapshot.json` files may need to be read with offset/limit parameters.
Start with the first 200 lines to get the structure, then target specific
sections.
- `inputs.jsonl` and `snapshots.jsonl` are newline-delimited JSON. Each line
is one tick. Read the last 10-20 lines first to focus on the moments before
the report was filed.
- The `render.txt` is a text-art representation of the game view. Entity
positions in the render should match positions in the snapshot. Mismatches
are themselves a bug signal (rendering vs simulation desync).
- The seed in `seed.txt` combined with `inputs.jsonl` enables deterministic
replay on the server. Note this in any ticket you create.
- If the bug-reports directory does not exist, the tester has not yet run any
gauntlet sessions or has not filed any reports. This is not an error.
+151
View File
@@ -0,0 +1,151 @@
---
name: d2-diagram
description: >
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
user says "create a diagram", "draw architecture", "make a flowchart",
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
Produces .d2 source files and renders them to PNG. Also use when asked
to update, re-render, or batch render existing diagrams.
---
# d2 Diagram Generation
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
external dependencies beyond the d2 binary.
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
## Project Defaults
| Setting | Value | Override |
|---------|-------|----------|
| Theme | 200 (Dark Mauve) | `--theme N` |
| Layout | dagre | `--layout elk` |
| Padding | 100px | — |
| Format | PNG | `--svg` |
## Output Convention
```
docs/diagrams/
architecture/ # System architecture, IPC, component layout
data-flow/ # Sequence diagrams, data pipelines
entity/ # ER diagrams, ECS component schemas
state/ # State machines, behavior trees
ui/ # UI navigation flow, screen transitions
```
Both `.d2` source and `.png` output are tracked in git.
## Single Diagram Workflow
1. **Determine category** — architecture, data-flow, entity, state, or ui
2. **Read template**`references/diagram-templates.md` for the matching category
3. **Read syntax**`references/d2-syntax-guide.md` if unfamiliar with d2 syntax
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
5. **Validate**`.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
6. **Render**`.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
7. **Read SVG** — verify the output, present to user
### Script Usage
```bash
# Render with project defaults
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
# Validate syntax only
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
# Auto-format source
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
# Sketch mode (hand-drawn look for drafts)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
# Light theme (for printable docs)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
# SVG output (if specifically needed)
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
```
## Batch Render
Re-render all diagrams after theme or style changes:
```bash
# All diagrams
.claude/skills/d2-diagram/scripts/d2-batch.sh
# One category
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
# Preview what would render
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
# Force re-render everything
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
```
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
## Advanced Patterns
### Variables for consistent styling
```d2
vars: {
color-bg: "#2a3040"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
```
### Multi-board (layers)
```d2
# Base diagram here
layers: {
detailed: {
# More detailed view
}
}
```
### Sequence diagrams
```d2
shape: sequence_diagram
client: Godot Client
server: Rust Server
client -> server: TickRequest(delta)
server -> client: WorldState(entities)
```
### Imports
Split shared definitions into a separate file and import:
```d2
...@shared-defs.d2
```
## Agent Guidance
- **Qatux** — Architecture decision records, system overview diagrams, data
schemas. Prefer architecture and entity templates.
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
system data flow. Prefer architecture and data-flow templates.
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
Prefer UI flow template.
## References
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
containers, styling, variables). Read when unfamiliar with d2 syntax.
- `references/diagram-templates.md` — Five category templates with complete
d2 source examples. Read when starting a new diagram.
@@ -0,0 +1,212 @@
# D2 Language Quick Reference
## Nodes
```d2
server # Implicit label from key
server: Simulation Server # Explicit label
server: Simulation Server { # With properties
shape: hexagon
style.fill: "#2d3436"
}
```
## Edges
```d2
a -> b # Directed
a <- b # Reverse directed
a <-> b # Bidirectional
a -- b # Undirected
a -> b: "label" # Labeled edge
a -> b -> c # Chained
```
## Containers (nesting)
```d2
infrastructure: {
server: Simulation Server
database: State Store {
shape: cylinder
}
}
```
## Shapes
| Shape | Use for |
|-------|---------|
| `rectangle` | Default. Components, modules, generic. |
| `hexagon` | Systems, services, major components. |
| `cylinder` | Databases, storage, persistent state. |
| `diamond` | Decisions, conditions, branch points. |
| `oval` / `circle` | Start/end states, events. |
| `cloud` | External systems, networks. |
| `person` | Actors, users, NPCs. |
| `queue` | Message queues, buffers. |
| `page` | Documents, files. |
| `package` | Packages, modules, crates. |
| `sql_table` | Database tables, ECS component schemas. |
| `class` | Class diagrams, ECS system definitions. |
| `code` | Code blocks (set `language` property). |
| `markdown` | Rich text blocks. |
## SQL Tables
```d2
entity: {
shape: sql_table
id: u64 {constraint: primary_key}
name: String
position: Vec2
faction_id: u64 {constraint: foreign_key}
}
```
## Class Diagrams
```d2
perception_system: {
shape: class
+run(world: &mut World)
-calculate_los(entity: Entity): HashSet<Entity>
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
}
```
## Sequence Diagrams
```d2
shape: sequence_diagram
client: Godot Client
server: Rust Server
client -> server: TickRequest(delta)
server -> server: run ECS systems
server -> client: WorldState(entities)
```
## Styling
```d2
node: Label {
style: {
fill: "#2d3436"
stroke: "#333340"
stroke-width: 2
stroke-dash: 5 # Dashed line
opacity: 0.8
font-size: 14
font-color: "#c8d0e0"
bold: true
italic: false
border-radius: 4
shadow: true
3d: true # Rectangles only
multiple: true # Stacked appearance
double-border: true # Rectangles/ovals only
}
}
```
### Edge styling
```d2
a -> b: {
style: {
stroke: "#c8d8f0"
stroke-width: 2
stroke-dash: 5
opacity: 0.8
animated: true # Animated flow
}
}
```
## Variables
```d2
vars: {
color-bg: "#1a1e24"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
node: {
style.fill: ${color-bg}
style.stroke: ${color-stroke}
style.font-color: ${color-text}
}
```
## Direction
```d2
direction: right # left-to-right (default for dagre)
direction: down # top-to-bottom
direction: up
direction: left
```
## Imports
```d2
...@shared-defs.d2 # Spread import (inline all definitions)
```
## Icons
```d2
node: Label {
icon: https://icons.terrastruct.com/essentials/time.svg
}
```
## Layers (multi-board)
```d2
# Base diagram content here
layers: {
detailed: {
# More detailed view
}
simplified: {
# Simplified overview
}
}
```
## Scenarios (animated transitions)
```d2
# Base state
scenarios: {
alert: {
# Changes from base for alert state
}
combat: {
# Changes from base for combat state
}
}
```
## Comments
```d2
# This is a comment
node: Label # Inline comment
```
## Project Colors (from visual-grammar-v01.md)
| Constant | Hex | Usage |
|----------|-----|-------|
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
| Zone 1 wall | `#2a3040` | Component fill |
| Outline standard | `#333340` | Borders, strokes |
| Insert chrome | `#c8d0e0` | Text, labels |
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
@@ -0,0 +1,251 @@
# Diagram Templates
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
---
## 1. Architecture Diagram
System components, relationships, communication channels.
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
```d2
vars: {
color-bg: "#2a3040"
color-stroke: "#333340"
color-text: "#c8d0e0"
color-accent: "#c8d8f0"
}
direction: right
client: Godot Client {
shape: hexagon
style.fill: ${color-bg}
style.font-color: ${color-text}
rendering: Rendering {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
ui: UI Layer {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
bridge: IPC Bridge {
style.fill: ${color-bg}
style.font-color: ${color-text}
style.stroke: ${color-accent}
}
}
server: Rust Server {
shape: hexagon
style.fill: ${color-bg}
style.font-color: ${color-text}
ecs: bevy_ecs {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
perception: Perception {
style.fill: ${color-bg}
style.font-color: ${color-text}
}
bridge: IPC Bridge {
style.fill: ${color-bg}
style.font-color: ${color-text}
style.stroke: ${color-accent}
}
}
client.bridge -> server.bridge: "stdin/stdout" {
style.stroke: ${color-accent}
style.stroke-dash: 5
}
```
---
## 2. Entity Relationship
Data schemas, ECS components, knowledge graph structure.
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
```d2
entity: Entity {
shape: sql_table
id: u64 {constraint: primary_key}
name: String
faction_id: u64 {constraint: foreign_key}
}
position: Position {
shape: sql_table
entity_id: u64 {constraint: foreign_key}
x: f32
y: f32
chunk_id: u32
}
knowledge: KnowledgeEntry {
shape: sql_table
observer_id: u64 {constraint: foreign_key}
subject_id: u64 {constraint: foreign_key}
fact_type: FactType
confidence: f32
last_seen_tick: u64
}
entity.id -> position.entity_id
entity.id -> knowledge.observer_id
entity.id -> knowledge.subject_id
```
---
## 3. Sequence / Data Flow
Ordered interactions between systems over time.
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
```d2
shape: sequence_diagram
client: Godot Client
bridge: IPC Bridge
server: Rust Server
ecs: ECS Systems
client -> bridge: TickRequest(delta, input)
bridge -> server: deserialize + dispatch
server -> ecs: run_systems(delta)
ecs -> ecs: perception, AI, physics
ecs -> server: collect WorldState
server -> bridge: serialize WorldState
bridge -> client: WorldState(entities, events)
client -> client: update rendering
```
---
## 4. State Machine
Entity states, transitions, conditions.
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
**Agents:** Tyre (behavior system design), Qatux (state documentation).
```d2
vars: {
color-state: "#2a3040"
color-text: "#c8d0e0"
color-edge: "#c8d8f0"
color-decision: "#333340"
}
idle: Idle {
style.fill: ${color-state}
style.font-color: ${color-text}
}
alert: Alert {
style.fill: ${color-state}
style.font-color: ${color-text}
}
investigate: Investigate {
style.fill: ${color-state}
style.font-color: ${color-text}
}
combat: Combat {
style.fill: ${color-state}
style.font-color: ${color-text}
style.stroke: "#f0b840"
}
flee: Flee {
style.fill: ${color-state}
style.font-color: ${color-text}
}
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
```
---
## 5. UI Flow
Screen navigation, component hierarchy, interaction paths.
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
```d2
vars: {
color-screen: "#1a1e24"
color-panel: "#2a3040"
color-text: "#c8d0e0"
color-nav: "#c8d8f0"
}
gameplay: Gameplay {
style.fill: ${color-screen}
style.font-color: ${color-text}
hud: HUD {
style.fill: ${color-panel}
style.font-color: ${color-text}
minimap: Minimap
monologue: Monologue Panel
insert_display: Insert Display
}
world: World View {
style.fill: ${color-panel}
style.font-color: ${color-text}
}
}
pause: Pause Menu {
style.fill: ${color-screen}
style.font-color: ${color-text}
inventory: Inventory
journal: Journal
settings: Settings
}
dialogue: Dialogue Mode {
style.fill: ${color-screen}
style.font-color: ${color-text}
speaker: Speaker Panel
responses: Response List
}
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
```
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
RENDER="$SCRIPT_DIR/d2-render.sh"
usage() {
cat <<EOF
Usage: $(basename "$0") [directory] [options]
Batch render all .d2 files in a directory. Skips files whose PNG is
newer than the source unless --force is used.
Options:
--dry-run List files that would be rendered
--force Re-render even if SVG is up to date
--theme N Override theme for all files
Examples:
$(basename "$0") # All in docs/diagrams/
$(basename "$0") docs/diagrams/architecture/ # One category
$(basename "$0") --dry-run # Preview
$(basename "$0") --force # Re-render everything
EOF
exit 1
}
DIR="$REPO_ROOT/docs/diagrams"
DRY_RUN=false
FORCE=false
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
--help|-h) usage ;;
*)
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
DIR="$1"
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
else
echo "Unknown option or directory: $1" >&2; exit 1
fi
shift
;;
esac
done
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
RENDERED=0
SKIPPED=0
FAILED=0
while IFS= read -r -d '' d2_file; do
png_file="${d2_file%.d2}.png"
# Skip if PNG is newer than source (unless --force)
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
SKIPPED=$((SKIPPED + 1))
continue
fi
rel_path="${d2_file#"$REPO_ROOT/"}"
if [[ "$DRY_RUN" == true ]]; then
echo "Would render: $rel_path"
RENDERED=$((RENDERED + 1))
continue
fi
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
RENDERED=$((RENDERED + 1))
else
echo "FAILED: $rel_path" >&2
FAILED=$((FAILED + 1))
fi
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
echo ""
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
set -euo pipefail
D2="/home/linuxbrew/.linuxbrew/bin/d2"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
DEFAULT_THEME=200
DEFAULT_LAYOUT="dagre"
DEFAULT_PAD=100
usage() {
cat <<EOF
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
Commands:
validate <file> Check syntax without rendering
fmt <file> Auto-format in place
Options:
--theme N Override theme (default: $DEFAULT_THEME)
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
--sketch Enable hand-drawn sketch mode
--output PATH Override output path (default: input with .png extension)
--svg Render to SVG instead of PNG
Examples:
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
EOF
exit 1
}
[[ $# -lt 1 ]] && usage
# Parse subcommand
SUBCMD=""
case "$1" in
validate|fmt)
SUBCMD="$1"
shift
;;
esac
[[ $# -lt 1 ]] && usage
INPUT="$1"
shift
# Resolve to absolute path
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
# Handle subcommands
if [[ -n "$SUBCMD" ]]; then
"$D2" "$SUBCMD" "$INPUT"
echo "OK: $SUBCMD $INPUT"
exit 0
fi
# Parse render options
THEME="$DEFAULT_THEME"
LAYOUT="$DEFAULT_LAYOUT"
SKETCH=""
OUTPUT=""
FORMAT="png"
while [[ $# -gt 0 ]]; do
case "$1" in
--theme) THEME="$2"; shift 2 ;;
--layout) LAYOUT="$2"; shift 2 ;;
--sketch) SKETCH="-s"; shift ;;
--output) OUTPUT="$2"; shift 2 ;;
--svg) FORMAT="svg"; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Derive output path
if [[ -z "$OUTPUT" ]]; then
OUTPUT="${INPUT%.d2}.$FORMAT"
fi
# Render
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
+197
View File
@@ -0,0 +1,197 @@
---
name: frame0-wireframe
description: >
Create and export UI wireframes using Frame0 (local desktop wireframing
app with HTTP API). Use when the user says "create wireframe", "wireframe
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
Wireframes are authored as local JSON files (source of truth) and synced
to Frame0 for rendering and export. Requires Frame0 to be running locally.
---
# Frame0 Wireframe Generation
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
the user explicitly says they have made edits in Frame0 and want to import
them. The pull workflow exists for that case only — do not use it proactively.
**Prerequisite:** Frame0 desktop app must be running. If not available,
stop and inform the user. Point to `references/setup-guide.md`.
## Health Check
Always check first:
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
```
## Core Workflow
1. **Health check** — verify Frame0 is running
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`
3. **Push to Frame0**`frame0-sync.py push <file.json>`
4. **Export PNG**`frame0-sync.py export <file.json> <output.png>`
5. **Clean up** — delete test/scratch pages from Frame0 when done
### Scripts
| Script | Purpose |
|--------|---------|
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
## Wireframe JSON Format
```json
{
"name": "Dialogue Box",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 170, "top": 500, "width": 800, "height": 260,
"fillColor": "#1a1e24",
"strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"speaker": {
"type": "Text",
"parent": "panel",
"left": 190, "top": 520,
"text": "LERA KONSTANTIN",
"fontColor": "#c8d0e0",
"fontSize": 16
},
"btn-ask": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 670, "width": 370, "height": 30,
"fillColor": "#2a3040",
"strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
}
},
"connectors": {
"flow-1": {
"tailId": "panel",
"headId": "btn-ask",
"strokeColor": "#c8d8f0"
}
}
}
```
### Key rules
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
- **`parent`** references another local shape ID for nesting
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
theme tokens for colors). The sync script handles the mapping transparently.
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
### Sync commands
```bash
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
# Push local JSON to Frame0 (clears page, recreates all shapes)
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
# Push + export as PNG in one step
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
docs/design/wireframes/dialogue/dialogue-box.png
```
### Batch export
Use this when exporting multiple wireframes. It runs as a single Bash call,
avoiding repeated permission prompts.
```bash
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
# Dry run first — shows full manifest, no Frame0 calls
$BATCH --dry-run
# Export everything (skips PNGs already newer than their JSON)
$BATCH
# Export one category only
$BATCH --category dialogue
# Force re-export of everything
$BATCH --force
```
**Always dry-run first, then get approval before running the live export.**
## Low-Level Commands
For ad-hoc operations or exec calls not covered by sync:
```bash
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
$CMD health
$CMD list-pages
$CMD current-page
$CMD get-page <page-id>
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
$CMD create-connector <tail-id> <head-id>
$CMD move <shape-id> <dx> <dy>
$CMD export --format image/png
$CMD exec "view:fit-to-screen" '{}'
```
If you find yourself using `exec` for the same command repeatedly, flag it as
a candidate for a proper wrapper in `frame0-cmd.sh`.
## Project Styling Defaults
Colors from `docs/design/visual-grammar-v01.md`:
| Role | Hex | Frame0 token |
|------|-----|-------------|
| Background | `#1a1e24` | `$sage3` |
| Stroke | `#333340` | `$slate6` |
| Fill | `#2a3040` | `$slate5` |
| Text | `#c8d0e0` | `$mint12` |
| Accent | `#c8d8f0` | `$blue12` |
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
## Output Convention
```
docs/design/wireframes/
hud/ # HUD layout wireframes
menus/ # Menu screen wireframes
dialogue/ # Dialogue box wireframes
insert/ # Neural insert wireframes
```
Both `.json` source and `.png` exports are tracked in git.
`.idmap.json` mapping files are gitignored.
## Agent Guidance
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
visual consistency. Uses all component library patterns.
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
- **Qatux** — Export wireframes for UI decision records and documentation.
## References
- `references/component-library.md` — Pre-built JSON wireframe templates
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
- `references/api-reference.md` — Frame0 HTTP API command reference and
type mappings. Read for low-level control.
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
@@ -0,0 +1,241 @@
# Frame0 HTTP API Reference
Frame0 exposes a local HTTP API when the desktop app is running.
## Endpoint
```
POST http://localhost:{port}/execute_command
Content-Type: application/json
```
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
## Request / Response
```json
{"command": "namespace:action", "args": { ... }}
```
```json
{"success": true, "data": { ... }}
{"success": false, "error": "description"}
```
---
## Type Mapping
Frame0 uses different type names for create vs get:
| Create API (`type`) | Get API (internal) | Description |
|--------------------|--------------------|-------------|
| `Rectangle` | `Box` | Rectangle with optional corners |
| `Ellipse` | `Ellipse` | Circle/ellipse |
| `Text` | `Text` | Text label |
| `Line` | `Line` | Line/polyline |
| `Frame` | `Frame` | Container from library |
| `Freehand` | `Freehand` | Freehand drawing |
| `Highlighter` | `Highlighter` | Highlighter stroke |
The sync script handles this mapping transparently.
## Color Tokens
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
| Hex | Token | Role |
|-----|-------|------|
| `#1a1e24` | `$sage3` | Background |
| `#2a3040` | `$slate5` | Fill |
| `#333340` | `$slate6` | Stroke |
| `#c8d0e0` | `$mint12` | Text |
| `#c8d8f0` | `$blue12` | Accent |
Both hex and token strings work in the API. Tokens are preserved on round-trip.
---
## Commands
### shape:create-shape
```json
{
"command": "shape:create-shape",
"args": {
"type": "Rectangle",
"shapeProps": {
"name": "my-button",
"left": 100, "top": 200, "width": 120, "height": 36,
"fillColor": "#2a3040",
"strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"parentId": "optional-parent-shape-id",
"convertColors": true
}
}
```
Returns: shape ID (string).
### shape:get-shape
```json
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
```
### shape:update-shape
```json
{
"command": "shape:update-shape",
"args": {
"shapeId": "id",
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
"convertColors": true
}
}
```
### shape:move
```json
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
```
### shape:create-connector
```json
{
"command": "shape:create-connector",
"args": {
"tailId": "source-id",
"headId": "target-id",
"shapeProps": {"strokeColor": "#c8d8f0"},
"convertColors": true
}
}
```
### shape:create-icon
```json
{
"command": "shape:create-icon",
"args": {
"iconName": "search",
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
}
}
```
### shape:get-available-icons
```json
{"command": "shape:get-available-icons", "args": {}}
```
### shape:group / shape:ungroup
```json
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
```
### edit:delete / edit:duplicate
```json
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
```
### page:add
```json
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
```
Returns: `{id, type, name}`.
### page:get
```json
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
```
### page:get-current-page
```json
{"command": "page:get-current-page", "args": {}}
```
Returns: page ID string.
### page:set-current-page
```json
{"command": "page:set-current-page", "args": {"pageId": "id"}}
```
### doc:get (list all pages)
```json
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
```
### page:delete
```json
{"command": "page:delete", "args": {"pageId": "id"}}
```
### file:export-image
```json
{
"command": "file:export-image",
"args": {
"pageId": "optional-page-id",
"format": "image/png",
"fillBackground": true
}
}
```
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
Returns: base64-encoded image data.
### view:fit-to-screen
```json
{"command": "view:fit-to-screen", "args": {}}
```
---
## Shape Properties
| Property | Type | Notes |
|----------|------|-------|
| `name` | string | Shape identifier/label |
| `left` | number | X position (origin: top-left) |
| `top` | number | Y position |
| `width` | number | Width in pixels |
| `height` | number | Height in pixels |
| `fillColor` | string | Hex or `$token` |
| `strokeColor` | string | Hex or `$token` |
| `strokeWidth` | number | Border width |
| `fontColor` | string | Text color (hex or `$token`) |
| `fontSize` | number | Font size in pixels |
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
| `text` | string | Text content |
| `wordWrap` | boolean | Enable word wrapping |
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
| `constraints` | array | Auto-sizing constraints |
| `horzAlign` | string | Horizontal text alignment |
| `vertAlign` | string | Vertical text alignment |
| `fillStyle` | string | Fill style (`none` for transparent) |
| `path` | array | Coordinate pairs for lines |
@@ -0,0 +1,467 @@
# Component Library
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
**Viewport:** 1140x780 (Godot project settings)
**Grid unit:** 8px
**Min touch target:** 36px height
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
---
## 1. HUD Layout
Main gameplay overlay. Minimap top-right, monologue bottom-center,
insert display bottom-left, action hints bottom-right.
```json
{
"name": "HUD Layout",
"shapes": {
"minimap": {
"type": "Rectangle",
"left": 880, "top": 20, "width": 240, "height": 240,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"minimap-label": {
"type": "Text",
"parent": "minimap",
"left": 890, "top": 30,
"text": "Minimap",
"fontColor": "#c8d0e0", "fontSize": 14
},
"monologue": {
"type": "Rectangle",
"left": 300, "top": 680, "width": 520, "height": 80,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"monologue-text": {
"type": "Text",
"parent": "monologue",
"left": 310, "top": 700, "width": 500,
"text": "Internal monologue text appears here...",
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
},
"insert": {
"type": "Rectangle",
"left": 20, "top": 600, "width": 260, "height": 160,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"insert-label": {
"type": "Text",
"parent": "insert",
"left": 30, "top": 620,
"text": "Neural Insert Data",
"fontColor": "#c8d0e0", "fontSize": 14
},
"hints": {
"type": "Rectangle",
"left": 880, "top": 700, "width": 240, "height": 60,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"hints-label": {
"type": "Text",
"parent": "hints",
"left": 890, "top": 720,
"text": "[E] Interact [TAB] Insert",
"fontColor": "#c8d0e0", "fontSize": 12
}
}
}
```
---
## 2. Dialogue Box
Speaker panel with response options. Anchored bottom-center during dialogue mode.
```json
{
"name": "Dialogue Box",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 170, "top": 500, "width": 800, "height": 260,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"speaker": {
"type": "Text",
"parent": "panel",
"left": 190, "top": 520,
"text": "LERA KONSTANTIN",
"fontColor": "#c8d0e0", "fontSize": 16
},
"text-area": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 550, "width": 760, "height": 100,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"dialogue-text": {
"type": "Text",
"parent": "text-area",
"left": 200, "top": 560, "width": 740,
"text": "You look like you could use a drink. First time on the station?",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
},
"btn-option1": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 670, "width": 370, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-option1-label": {
"type": "Text",
"parent": "btn-option1",
"left": 200, "top": 674,
"text": "[1] Ask about the station",
"fontColor": "#c8d8f0", "fontSize": 12
},
"btn-option2": {
"type": "Rectangle",
"parent": "panel",
"left": 190, "top": 710, "width": 370, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-option2-label": {
"type": "Text",
"parent": "btn-option2",
"left": 200, "top": 714,
"text": "[2] Ask about recent events",
"fontColor": "#c8d8f0", "fontSize": 12
},
"btn-leave": {
"type": "Rectangle",
"parent": "panel",
"left": 580, "top": 670, "width": 180, "height": 30,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
"corners": [4, 4, 4, 4]
},
"btn-leave-label": {
"type": "Text",
"parent": "btn-leave",
"left": 590, "top": 674,
"text": "[3] Leave",
"fontColor": "#c8d8f0", "fontSize": 12
}
}
}
```
---
## 3. Menu Screen
Full-screen menu with sidebar navigation and content area.
```json
{
"name": "Pause Menu",
"shapes": {
"bg": {
"type": "Rectangle",
"left": 0, "top": 0, "width": 1140, "height": 780,
"fillColor": "#1a1e24"
},
"nav": {
"type": "Rectangle",
"parent": "bg",
"left": 20, "top": 20, "width": 200, "height": 740,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"btn-inventory": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 40, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-inventory-label": {
"type": "Text", "parent": "btn-inventory",
"left": 40, "top": 48, "text": "Inventory",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-journal": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 86, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-journal-label": {
"type": "Text", "parent": "btn-journal",
"left": 40, "top": 94, "text": "Journal",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-map": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 132, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-map-label": {
"type": "Text", "parent": "btn-map",
"left": 40, "top": 140, "text": "Map",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-settings": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 178, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-settings-label": {
"type": "Text", "parent": "btn-settings",
"left": 40, "top": 186, "text": "Settings",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-resume": {
"type": "Rectangle", "parent": "nav",
"left": 30, "top": 720, "width": 180, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-resume-label": {
"type": "Text", "parent": "btn-resume",
"left": 40, "top": 728, "text": "Resume",
"fontColor": "#c8d8f0", "fontSize": 14
},
"content": {
"type": "Rectangle",
"parent": "bg",
"left": 240, "top": 20, "width": 880, "height": 740,
"fillColor": "#2a3040", "strokeColor": "#333340",
"corners": [4, 4, 4, 4]
},
"content-label": {
"type": "Text", "parent": "content",
"left": 260, "top": 40,
"text": "Content area",
"fontColor": "#c8d0e0", "fontSize": 14
}
}
}
```
---
## 4. Modal Dialog
Centered overlay for confirmations, alerts, choices.
```json
{
"name": "Modal Dialog",
"shapes": {
"overlay": {
"type": "Rectangle",
"left": 0, "top": 0, "width": 1140, "height": 780,
"fillColor": "#0a0c10"
},
"modal": {
"type": "Rectangle",
"parent": "overlay",
"left": 320, "top": 240, "width": 500, "height": 300,
"fillColor": "#1a1e24", "strokeColor": "#333340",
"corners": [8, 8, 8, 8]
},
"title": {
"type": "Text", "parent": "modal",
"left": 340, "top": 260,
"text": "Confirm Action",
"fontColor": "#c8d0e0", "fontSize": 18
},
"divider": {
"type": "Line", "parent": "modal",
"left": 340, "top": 290, "width": 460, "height": 0,
"strokeColor": "#333340"
},
"body-1": {
"type": "Text", "parent": "modal",
"left": 340, "top": 310,
"text": "Are you sure you want to proceed?",
"fontColor": "#c8d0e0", "fontSize": 14
},
"body-2": {
"type": "Text", "parent": "modal",
"left": 340, "top": 340,
"text": "This action cannot be undone.",
"fontColor": "#c8d0e0", "fontSize": 14
},
"btn-cancel": {
"type": "Rectangle", "parent": "modal",
"left": 480, "top": 480, "width": 120, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-cancel-label": {
"type": "Text", "parent": "btn-cancel",
"left": 510, "top": 488,
"text": "Cancel",
"fontColor": "#c8d8f0", "fontSize": 14
},
"btn-confirm": {
"type": "Rectangle", "parent": "modal",
"left": 620, "top": 480, "width": 120, "height": 36,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"btn-confirm-label": {
"type": "Text", "parent": "btn-confirm",
"left": 645, "top": 488,
"text": "Confirm",
"fontColor": "#c8d8f0", "fontSize": 14
}
}
}
```
---
## 5. List View
Scrollable list with item selection and detail panel.
```json
{
"name": "List View",
"shapes": {
"list-panel": {
"type": "Rectangle",
"left": 20, "top": 20, "width": 400, "height": 740,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-1": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 30, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
},
"item-1-label": {
"type": "Text", "parent": "item-1",
"left": 40, "top": 38, "text": "Item Alpha",
"fontColor": "#c8d8f0", "fontSize": 14
},
"item-2": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 80, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-2-label": {
"type": "Text", "parent": "item-2",
"left": 40, "top": 88, "text": "Item Beta",
"fontColor": "#c8d0e0", "fontSize": 14
},
"item-3": {
"type": "Rectangle", "parent": "list-panel",
"left": 30, "top": 130, "width": 380, "height": 40,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"item-3-label": {
"type": "Text", "parent": "item-3",
"left": 40, "top": 138, "text": "Item Gamma",
"fontColor": "#c8d0e0", "fontSize": 14
},
"detail-panel": {
"type": "Rectangle",
"left": 440, "top": 20, "width": 680, "height": 740,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail-title": {
"type": "Text", "parent": "detail-panel",
"left": 460, "top": 40,
"text": "Item Alpha",
"fontColor": "#c8d0e0", "fontSize": 18
},
"detail-body": {
"type": "Text", "parent": "detail-panel",
"left": 460, "top": 80, "width": 640,
"text": "Description and properties appear here.",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
}
}
}
```
---
## 6. Inventory Grid
Grid of cells for item management.
```json
{
"name": "Inventory Grid",
"shapes": {
"panel": {
"type": "Rectangle",
"left": 240, "top": 100, "width": 660, "height": 580,
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
},
"title": {
"type": "Text", "parent": "panel",
"left": 260, "top": 120,
"text": "INVENTORY",
"fontColor": "#c8d0e0", "fontSize": 18
},
"cell-1-1": {
"type": "Rectangle", "parent": "panel",
"left": 260, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-2": {
"type": "Rectangle", "parent": "panel",
"left": 332, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-3": {
"type": "Rectangle", "parent": "panel",
"left": 404, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-1-4": {
"type": "Rectangle", "parent": "panel",
"left": 476, "top": 160, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-1": {
"type": "Rectangle", "parent": "panel",
"left": 260, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-2": {
"type": "Rectangle", "parent": "panel",
"left": 332, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-3": {
"type": "Rectangle", "parent": "panel",
"left": 404, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"cell-2-4": {
"type": "Rectangle", "parent": "panel",
"left": 476, "top": 232, "width": 64, "height": 64,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail": {
"type": "Rectangle", "parent": "panel",
"left": 580, "top": 160, "width": 300, "height": 400,
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
},
"detail-title": {
"type": "Text", "parent": "detail",
"left": 600, "top": 180,
"text": "Selected Item Name",
"fontColor": "#c8d0e0", "fontSize": 16
},
"detail-body": {
"type": "Text", "parent": "detail",
"left": 600, "top": 210, "width": 260,
"text": "Item description and stats",
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
}
}
}
```
@@ -0,0 +1,53 @@
# Frame0 Setup Guide
## Installation (Fedora)
Download from https://frame0.app/download and install the RPM:
```bash
sudo dnf install ./frame0-*.x86_64.rpm
```
Requires: Fedora 40 or later (x86_64).
## Starting Frame0
Launch the desktop application:
```bash
frame0 &
```
Frame0 exposes an HTTP API at `localhost:58320` when running.
## Verify API Access
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
```
Expected output: `Frame0 is running on port 58320`
## Port Configuration
Default port: **58320**
To use a different port, set the environment variable:
```bash
export FRAME0_PORT=58321
```
Or pass `--port` to any script:
```bash
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| "Connection refused" | Frame0 not running | Start the desktop app |
| "Port in use" | Another instance running | Close duplicate or use different port |
| Script hangs | API unresponsive | Restart Frame0 |
+253
View File
@@ -0,0 +1,253 @@
#!/bin/bash
set -euo pipefail
PORT="${FRAME0_PORT:-58320}"
ENDPOINT="http://localhost:${PORT}/execute_command"
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [args...] [--port N]
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
curl calls. Requires Frame0 desktop app to be running.
Commands:
health Check if Frame0 is running
exec <namespace:action> <json> Execute a raw API command
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
get-shape <id> Get shape details
update-shape <id> <json-props> Update shape properties
delete <id> [id...] Delete shapes by ID
move <id> <dx> <dy> Move a shape by pixel offset
duplicate <id> Duplicate a shape
group <id> [id...] Group shapes
ungroup <group-id> Ungroup a group
create-connector <tail-id> <head-id> [json-props] Connect two shapes
create-icon <name> <json-props> Create an icon shape
add-page <name> Add a new page (becomes current)
get-page [page-id] Get current or specific page data
list-pages [--shapes] List all pages (--shapes for shape data)
current-page Get current page ID
set-page <page-id> Set current page
export [page-id] [--format mime] Export page as image (default: image/png)
fit Fit view to screen
Options:
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
Examples:
$(basename "$0") health
$(basename "$0") add-page "HUD Layout"
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
$(basename "$0") list-pages
$(basename "$0") export --format image/png
EOF
exit 1
}
# Parse --port from anywhere in args
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]+"${ARGS[@]}"}"
[[ $# -lt 1 ]] && usage
# Execute a Frame0 API command, return data or error
frame0_exec() {
local command="$1"
local args
args="${2:-"{}"}"
local response
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
return 1
}
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != 2* ]]; then
echo "ERROR: HTTP $http_code from Frame0" >&2
echo "$body" >&2
return 1
fi
# Parse success/error from response
python3 -c "
import sys, json
try:
r = json.loads(sys.stdin.read())
if r.get('success'):
d = r.get('data')
if d is not None:
print(json.dumps(d, indent=2))
else:
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
sys.exit(1)
" <<< "$body"
}
# Build JSON array from remaining args
ids_to_json_array() {
local arr="["
local first=true
for id in "$@"; do
[[ "$first" == true ]] && first=false || arr+=","
arr+="\"$id\""
done
arr+="]"
echo "$arr"
}
CMD="${1:-}"
shift || true
case "$CMD" in
health)
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
echo "Frame0 is running on port $PORT"
else
echo "Frame0 is NOT running on port $PORT" >&2
echo "Start Frame0 desktop app, then retry." >&2
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
exit 1
fi
;;
exec)
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
frame0_exec "$1" "$2"
;;
create-shape)
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
local_type="$1"
local_props="$2"
local_parent="${3:-}"
local_parent_arg=""
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
;;
get-shape)
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
;;
update-shape)
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
;;
delete)
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
local_arr=$(ids_to_json_array "$@")
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
;;
move)
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
;;
duplicate)
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
local_dx="${2:-0}"
local_dy="${3:-0}"
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
;;
group)
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
local_arr=$(ids_to_json_array "$@")
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
;;
ungroup)
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
;;
create-connector)
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
local_props="${3:-{}}"
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
;;
create-icon)
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
;;
add-page)
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
;;
get-page)
if [[ $# -ge 1 ]]; then
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
else
local_id
local_id=$(frame0_exec "page:get-current-page")
# Strip quotes from returned ID
local_id=$(echo "$local_id" | tr -d '"')
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
fi
;;
list-pages)
local_shapes="false"
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
;;
current-page)
frame0_exec "page:get-current-page"
;;
set-page)
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
;;
export)
local_page_id=""
local_format="image/png"
while [[ $# -gt 0 ]]; do
case "$1" in
--format) local_format="$2"; shift 2 ;;
*) local_page_id="$1"; shift ;;
esac
done
local_page_arg=""
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
;;
fit)
frame0_exec "view:fit-to-screen"
;;
--help|-h|help)
usage
;;
*)
echo "Unknown command: $CMD" >&2
usage
;;
esac
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Batch export wireframes from JSON to PNG via Frame0.
#
# Finds all .json wireframe files under docs/design/wireframes/ and exports
# each to a matching .png. Skips files whose PNG is already newer than the
# JSON, unless --force is passed.
#
# Usage:
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
#
# Options:
# --dry-run Print manifest only, don't touch Frame0.
# --force Re-export even if PNG already exists and is up to date.
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
# --root DIR Wireframes root dir (default: docs/design/wireframes)
#
# Exit codes:
# 0 All exports succeeded (or nothing to do)
# 1 One or more exports failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SYNC="$SCRIPT_DIR/frame0-sync.py"
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
DRY_RUN=false
FORCE=false
CATEGORY=""
WF_ROOT="$DEFAULT_ROOT"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
--category) CATEGORY="$2"; shift 2 ;;
--root) WF_ROOT="$2"; shift 2 ;;
-h|--help)
sed -n '/^# /p' "$0" | sed 's/^# //'
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ ! -d "$WF_ROOT" ]]; then
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
exit 1
fi
# Collect JSON files, optionally filtered by category subdirectory
mapfile -t JSON_FILES < <(
if [[ -n "$CATEGORY" ]]; then
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
else
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
fi
)
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
echo "No wireframe JSON files found."
exit 0
fi
# Classify files into to-export and to-skip
TO_EXPORT=()
TO_SKIP=()
for json in "${JSON_FILES[@]}"; do
png="${json%.json}.png"
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
TO_EXPORT+=("$json")
else
TO_SKIP+=("$json")
fi
done
# Print manifest
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
echo ""
echo "Will export (${#TO_EXPORT[@]} files):"
for json in "${TO_EXPORT[@]}"; do
png="${json%.json}.png"
rel="${json#$REPO_ROOT/}"
if [[ ! -f "$png" ]]; then
status="new"
else
status="updated"
fi
printf " [%-7s] %s\n" "$status" "$rel"
done
else
echo ""
echo "Nothing to export (all PNGs up to date)."
fi
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
echo ""
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
for json in "${TO_SKIP[@]}"; do
rel="${json#$REPO_ROOT/}"
printf " [skip ] %s\n" "$rel"
done
fi
if $DRY_RUN; then
echo ""
echo "Dry run — no exports performed."
exit 0
fi
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
exit 0
fi
echo ""
PASSED=0
FAILED=0
FAILED_FILES=()
TOTAL=${#TO_EXPORT[@]}
IDX=0
for json in "${TO_EXPORT[@]}"; do
IDX=$((IDX + 1))
png="${json%.json}.png"
rel="${json#$REPO_ROOT/}"
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
rc=$?
if [[ $rc -eq 0 ]]; then
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
echo "ok $size"
PASSED=$((PASSED + 1))
else
echo "FAILED"
cat /tmp/frame0-batch-err.txt >&2
FAILED=$((FAILED + 1))
FAILED_FILES+=("$rel")
fi
done
echo ""
echo "$PASSED exported, $FAILED failed."
if [[ $FAILED -gt 0 ]]; then
echo ""
echo "Failed:" >&2
for f in "${FAILED_FILES[@]}"; do
echo " $f" >&2
done
exit 1
fi
exit 0
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
Local JSON is source of truth. Frame0 is a renderer.
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
Usage:
frame0-sync.py push <wireframe.json> [--port PORT]
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
DEFAULT_PORT = 58320
def api(port, command, args=None):
"""Execute a Frame0 API command. Returns the data field on success."""
url = f"http://localhost:{port}/execute_command"
payload = json.dumps({"command": command, "args": args or {}}).encode()
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
sys.exit(1)
if not result.get("success"):
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
sys.exit(1)
return result.get("data")
# -- Mapping file management --------------------------------------------------
def mapping_path(wireframe_path):
"""Derive the idmap path from the wireframe JSON path."""
d = os.path.dirname(wireframe_path)
base = os.path.splitext(os.path.basename(wireframe_path))[0]
return os.path.join(d, f".{base}.idmap.json")
def load_mapping(wireframe_path):
p = mapping_path(wireframe_path)
if os.path.exists(p):
with open(p) as f:
return json.load(f)
return {"page_id": None, "shapes": {}, "connectors": {}}
def save_mapping(wireframe_path, mapping):
p = mapping_path(wireframe_path)
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
with open(p, "w") as f:
json.dump(mapping, f, indent=2)
f.write("\n")
# -- Push: local JSON -> Frame0 -----------------------------------------------
# Frame0 returns different type names from get vs what create accepts.
# Map internal types back to create-API types.
TYPE_TO_CREATE = {
"Box": "Rectangle",
"box": "Rectangle",
}
def topo_sort_shapes(shapes):
"""Sort shape IDs so parents come before children."""
order = []
visited = set()
def visit(sid):
if sid in visited:
return
visited.add(sid)
shape = shapes[sid]
parent = shape.get("parent")
if parent and parent in shapes:
visit(parent)
order.append(sid)
for sid in shapes:
visit(sid)
return order
def find_or_create_page(port, name, mapping):
"""Find existing page by mapping or name, or create a new one."""
# Try mapped page_id first
if mapping.get("page_id"):
try:
page = api(port, "page:get", {"pageId": mapping["page_id"]})
if page:
return mapping["page_id"]
except SystemExit:
pass # Page no longer exists, fall through
# Search by name in existing pages
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
if doc and "children" in doc:
for page in doc["children"]:
if page.get("name") == name:
return page["id"]
# Create new page
page = api(port, "page:add", {"pageProps": {"name": name}})
return page["id"]
def clear_page(port, page_id):
"""Delete all shapes on a page."""
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
if not page or "children" not in page:
return
shape_ids = [s["id"] for s in page["children"]]
if shape_ids:
api(port, "edit:delete", {"shapeIdArray": shape_ids})
def push(wireframe_path, port):
"""Push local wireframe JSON to Frame0."""
with open(wireframe_path) as f:
wireframe = json.load(f)
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
shapes = wireframe.get("shapes", {})
connectors = wireframe.get("connectors", {})
mapping = load_mapping(wireframe_path)
# Find or create page
page_id = find_or_create_page(port, name, mapping)
mapping["page_id"] = page_id
# Switch to page and clear it
api(port, "page:set-current-page", {"pageId": page_id})
clear_page(port, page_id)
# Reset ID mappings (shapes are recreated)
mapping["shapes"] = {}
mapping["connectors"] = {}
# Create shapes in dependency order
order = topo_sort_shapes(shapes)
for local_id in order:
shape = shapes[local_id]
raw_type = shape.get("type", "Rectangle")
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
parent_local = shape.get("parent")
# Build shapeProps from everything except meta fields
meta_keys = {"type", "parent"}
props = {k: v for k, v in shape.items() if k not in meta_keys}
# Set name to local_id if not explicitly set
if "name" not in props:
props["name"] = local_id
create_args = {
"type": shape_type,
"shapeProps": props,
"convertColors": True,
}
# Resolve parent ID
if parent_local and parent_local in mapping["shapes"]:
create_args["parentId"] = mapping["shapes"][parent_local]
f0_id = api(port, "shape:create-shape", create_args)
mapping["shapes"][local_id] = f0_id
# Create connectors
for local_id, conn in connectors.items():
tail_local = conn.get("tailId")
head_local = conn.get("headId")
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
continue
meta_keys = {"tailId", "headId"}
props = {k: v for k, v in conn.items() if k not in meta_keys}
if "name" not in props:
props["name"] = local_id
f0_id = api(port, "shape:create-connector", {
"tailId": mapping["shapes"][tail_local],
"headId": mapping["shapes"][head_local],
"shapeProps": props,
"convertColors": True,
})
mapping["connectors"][local_id] = f0_id
# Fit to screen
api(port, "view:fit-to-screen")
save_mapping(wireframe_path, mapping)
total = len(mapping["shapes"]) + len(mapping["connectors"])
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
# -- Pull: Frame0 -> local JSON -----------------------------------------------
def pull(page_ref, output_path, port):
"""Pull a Frame0 page into local wireframe JSON."""
# Resolve page_ref: could be an ID or a name
page_id = None
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
if doc and "children" in doc:
for page in doc["children"]:
if page["id"] == page_ref or page.get("name") == page_ref:
page_id = page["id"]
page_name = page.get("name", page_ref)
break
if not page_id:
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
sys.exit(1)
# Load existing mapping for reverse lookup
mapping = load_mapping(output_path)
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
# Get full page with shapes
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
shapes = {}
connectors = {}
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
auto_id_counter = [0]
def auto_id(f0_shape):
"""Generate a stable local ID from shape name or auto-number."""
# Prefer existing mapping
f0_id = f0_shape["id"]
if f0_id in reverse_map:
return reverse_map[f0_id]
# Use sanitized name
name = f0_shape.get("name", "")
if name:
sanitized = name.lower().replace(" ", "-").replace("_", "-")
if sanitized not in shapes:
return sanitized
# Fallback: auto-number
auto_id_counter[0] += 1
return f"s{auto_id_counter[0]:03d}"
def process_shape(f0_shape, parent_local_id=None):
f0_id = f0_shape["id"]
local_id = auto_id(f0_shape)
new_mapping["shapes"][local_id] = f0_id
# Extract shape properties — only strip structural keys that our
# ID mapping replaces. Everything else passes through as-is so the
# local JSON speaks Frame0's native vocabulary.
shape_type = f0_shape.get("type", "Box")
skip_keys = {"id", "type", "children", "pageId", "parentId"}
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
entry = {"type": shape_type}
if parent_local_id:
entry["parent"] = parent_local_id
entry.update(props)
# Remove name if it matches local_id (redundant)
if entry.get("name") == local_id:
del entry["name"]
shapes[local_id] = entry
# Process children recursively
for child in f0_shape.get("children", []):
child_type = child.get("type", "")
if child_type == "Connector":
process_connector(child)
else:
process_shape(child, local_id)
def process_connector(f0_conn):
f0_id = f0_conn["id"]
local_id = reverse_conn.get(f0_id)
if not local_id:
auto_id_counter[0] += 1
local_id = f"c{auto_id_counter[0]:03d}"
new_mapping["connectors"][local_id] = f0_id
tail_f0 = f0_conn.get("tail", {}).get("id")
head_f0 = f0_conn.get("head", {}).get("id")
entry = {}
if tail_f0:
# Will be resolved after all shapes are processed
entry["_tailF0"] = tail_f0
if head_f0:
entry["_headF0"] = head_f0
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
entry.update(props)
connectors[local_id] = entry
# Process all top-level shapes
for child in page.get("children", []):
child_type = child.get("type", "")
if child_type == "Connector":
process_connector(child)
else:
process_shape(child)
# Resolve connector references to local IDs
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
for conn in connectors.values():
tail_f0 = conn.pop("_tailF0", None)
head_f0 = conn.pop("_headF0", None)
if tail_f0 and tail_f0 in f0_to_local:
conn["tailId"] = f0_to_local[tail_f0]
if head_f0 and head_f0 in f0_to_local:
conn["headId"] = f0_to_local[head_f0]
wireframe = {"name": page_name}
if shapes:
wireframe["shapes"] = shapes
if connectors:
wireframe["connectors"] = connectors
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w") as f:
json.dump(wireframe, f, indent=2)
f.write("\n")
save_mapping(output_path, new_mapping)
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
# -- Export: push then export as image -----------------------------------------
def export_image(wireframe_path, output_path, port, fmt="image/png"):
"""Push wireframe to Frame0 and export the page as an image."""
import base64
# Push first to ensure Frame0 is up to date
push(wireframe_path, port)
mapping = load_mapping(wireframe_path)
page_id = mapping.get("page_id")
if not page_id:
print("ERROR: No page_id in mapping after push", file=sys.stderr)
sys.exit(1)
image_b64 = api(port, "file:export-image", {
"pageId": page_id,
"format": fmt,
"fillBackground": True,
})
image_bytes = base64.b64decode(image_b64)
with open(output_path, "wb") as f:
f.write(image_bytes)
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
# -- CLI -----------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
sub = parser.add_subparsers(dest="command")
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
p_push.add_argument("wireframe", help="Path to wireframe .json file")
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
p_pull.add_argument("page", help="Page ID or page name")
p_pull.add_argument("output", help="Output .json path")
p_export = sub.add_parser("export", help="Push and export as image")
p_export.add_argument("wireframe", help="Path to wireframe .json file")
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
p_export.add_argument("--format", default="image/png",
help="Export MIME type (default: image/png)")
args = parser.parse_args()
if args.command == "push":
push(args.wireframe, args.port)
elif args.command == "pull":
pull(args.page, args.output, args.port)
elif args.command == "export":
export_image(args.wireframe, args.output, args.port, args.format)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+34 -13
View File
@@ -63,23 +63,44 @@ If the diff is empty, report "No changes to review" and stop.
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
2. Read source files directly from the branch:
`git show origin/<branch>:<path>`
2. Read source files directly from the branch worktree (see below).
For large diffs (>1000 lines of source), provide **source files** rather than
raw diff to reviewers — cleaner context, better reviews. Read files with
`git show origin/<branch>:<path>` and include them in the prompt.
raw diff to reviewers — cleaner context, better reviews.
**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access.
Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**.
Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**.
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
worktrees. Each team branch is checked out at:
For agents without Bash, you MUST read the source files yourself (via
`git show origin/<branch>:<path>`) and **paste the file contents directly
into the agent prompt**. Do not tell these agents to read files — they can't.
For very large PRs, read the key files (new/heavily modified) and include
summaries or excerpts of minor changes. Also read and include the relevant
`decisions/*.md` files these agents need for context.
```
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
```
For example, the `copy` branch lives at:
```
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
```
**All reviewer agents** (regardless of Bash access) should read source files
from the worktree path using the Read tool. This is more reliable than
`git show origin/<branch>:<path>` because:
- All agents have Read access (no Bash dependency)
- Files are always the actual branch checkout (no stale cache)
- No risk of accidentally reading from main's working directory
When constructing reviewer prompts, tell agents to read files from the
worktree path. Example instruction for agents:
```
Read the changed files from the branch worktree. The branch is checked
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
```
Also tell agents to read relevant `decisions/*.md` files from the same
worktree (they're identical to main, but using the worktree path keeps
agents grounded in the correct directory).
### 4. Spawn reviewers in parallel
@@ -2,11 +2,20 @@
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**All agents read from worktree paths.** Each branch is checked out at:
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
Tell every reviewer agent to read source files from the worktree using the
Read tool. Include the worktree base path and a list of changed files in
every prompt. Do NOT rely on `git show` or paste file contents — agents
can read directly from the worktree.
## Code reviews (`server`, `client`, `ci`)
**Hoshe (Code Quality)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Include source code and commit log. Ask Hoshe to review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read source files from the worktree, then review for:
- Correctness and bug risks
- Error handling gaps
- Test coverage (are new features tested?)
@@ -16,44 +25,43 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**Tyre (Architecture)**
- `subagent_type`: `tyre`, `model`: `sonnet`
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
`decisions/*.md` files first, then review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
first, then review for:
- Architectural consistency with project decisions
- API/interface design quality
- Dependency and coupling concerns
- Scalability implications
- Whether the change respects non-negotiable baselines (D-010, D-012)
- Tyre can read files directly from the branch using `git show origin/<branch>:<path>`
## Copy reviews (`copy`)
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- Formatting consistency (markdown, file naming, frontmatter)
- Broken references or links
- Spelling and grammar
- File organization and structure
- Missing or orphaned files
**Paula (Narrative Depth)** — NO BASH ACCESS
**Paula (Narrative Depth)**
- `subagent_type`: `paula`, `model`: `sonnet`
- Paula cannot read from branches. You must paste file contents and decision
files directly into the prompt.
- Prompt: Include full text of changed files, commit log, and relevant
`decisions/*.md` content. Ask Paula to review for:
- Prompt: Provide the worktree path, list of changed files, commit log, and
list of relevant `decisions/*.md` files to read. Tell Paula to read all
files from the worktree using the Read tool, then review for:
- Narrative quality and character voice consistency
- Whether dialogue and monologue feel authentic to the characters
- Consequences and stakes — do choices carry weight?
- Political and interpersonal depth
- Emotional resonance — does the text make you feel something?
**Miri (World Consistency)** — NO BASH ACCESS
**Miri (World Consistency)**
- `subagent_type`: `miri`, `model`: `sonnet`
- Miri cannot read from branches. You must paste file contents and decision
files directly into the prompt.
- Prompt: Include full text of changed files, commit log, and relevant
`decisions/*.md` content. Ask Miri to review for:
- Prompt: Provide the worktree path, list of changed files, commit log, and
list of relevant `decisions/*.md` files to read. Tell Miri to read all
files from the worktree using the Read tool, then review for:
- Lore accuracy — do facts match established setting?
- Internal consistency across files
- IP originality — nothing should read as a copy from another franchise
@@ -64,16 +72,18 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- File format and naming conventions
- Asset organization and directory structure
- Missing or broken references in scene/resource files
- Import settings consistency
**Araminta (Art Direction)** — HAS BASH ACCESS
**Araminta (Art Direction)**
- `subagent_type`: `araminta`, `model`: `sonnet`
- Prompt: Include the changed files and commit log. Tell Araminta to read
the style guide and relevant design docs first, then review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Araminta to read the style guide and relevant design docs from the
worktree first, then review for:
- Visual consistency with the established style guide
- Color palette adherence
- UI pattern consistency (diegetic-first, clarity over beauty)
@@ -84,17 +94,17 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- File format and naming conventions
- Audio asset organization and directory structure
- Missing or broken references
- Import/bus configuration consistency
**Ozzie (Player Experience)** — NO BASH ACCESS
**Ozzie (Player Experience)**
- `subagent_type`: `ozzie`, `model`: `sonnet`
- Ozzie cannot read from branches. You must paste file contents directly
into the prompt.
- Prompt: Include full text of changed files and commit log. Ask Ozzie to
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Ozzie to read all files from the worktree using the Read tool, then
review for:
- Emotional impact — does the audio enhance the moment?
- Atmosphere and tone — does it feel like the Commonwealth?
+43 -1
View File
@@ -45,7 +45,38 @@ Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
First, check whether the sprint's work is actually done:
```bash
db/connectors/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
Also check for open PRs that may contain completed work waiting for
review or merge:
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Report the full picture to the user:
- Ticket progress (done / in_progress / backlog counts)
- Open PRs (if any — these represent work that's done but not merged)
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
exist**, do NOT close the sprint. Instead, report the current progress
and ask the user what they want to do:
- **Continue working** — switch to a team branch and run `/sprint-start`
there to resume work
- **Review & merge PRs first** — (only if open PRs exist) merge pending
work before deciding whether to close
- **Close anyway** — proceed with the close workflow below (carries over
unfinished tickets)
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
explicitly chooses to close.
#### A1. Close the active sprint
@@ -257,6 +288,17 @@ Task(
prompt: "You are on the {team} team for Sprint {N}.
Branch: `{team}`
RULES:
- GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead.
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
db/connectors/ticket show 528
db/connectors/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
2. Read the decision files referenced in the briefing.
3. Check TaskList for available work.
+4 -3
View File
@@ -88,9 +88,10 @@ When all Round N tasks are complete:
Wrap-up sequence:
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
2. If SI is present, SI creates tickets from decided items
3. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
4. TeamDelete to clean up
2. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop
3. If SI is present, SI creates tickets from decided items
4. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
5. TeamDelete to clean up
## Agent Type Reference
+2 -4
View File
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
If clean, report the result (fast-forward or merge commit, files changed).
If conflicts, report them and stop.
### 3. Push prompt
### 3. Push
After a successful merge, ask the user if they want to push:
After a successful merge, push the branch:
```bash
git push origin <current-branch>
```
Never push without explicit confirmation.
+3
View File
@@ -21,6 +21,9 @@ renderer/output/*.png
# Database (shared across worktrees at ../settledreach.db, not tracked)
db/commonwealth.db*
# Frame0 ID mapping files (ephemeral, per-machine)
*.idmap.json
# Python
__pycache__/
*.pyc
+133
View File
@@ -6,6 +6,139 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.15] — 2026-02-23
### Added
- Sprint 16 "Converse" briefings — 8 tickets across server/client/copy/visual teams
- 19 UI wireframes — HUD, dialogue, monologue, popups, menus in v0.1 and v1.0 variants with D-record cross-references
- d2-diagram skill — text-to-diagram generation with project defaults (theme 200, dagre, PNG)
- frame0-wireframe skill — UI wireframing via Frame0 HTTP API, replaces MCP dependency with bash+curl
- 16 decision diagrams — architecture, data-flow, entity, state, and UI categories covering all project decisions
### Changed
- frame0-wireframe skill rewritten — JSON-as-truth workflow with frame0-sync.py, batch export, renderer-only guidance
- pr-review skill — all reviewer agents now use worktree paths instead of git show
- Dialogue panel is always visible as permanent insert UI element (D-061)
- Makefile: check-protocol target verifies server/client protocol versions match before build
- D-035 amended: line ID namespace changed from location-scoped to NPC-scoped (Sprint 15)
- Tilemap z-layer filtering — FloorTiles renders z=0 only, z=1/z>1 reserved for future layer nodes (#71, D-049)
- Entity 24x32 footprint per D-044 visual hierarchy — split ENTITY_SIZE into WIDTH/HEIGHT with separate offsets (#72)
- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb
- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117)
- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions
- SpatialIndex trait with naive Vec implementation — entities_in_range, entities_at, update methods with Manhattan distance (#340)
- NPC generation pipeline — procedural seeding of all 10 D-024 axes via SimRng with constraint validation (#92)
- Personality and tell system — 5 tell categories (Nervous, Angry, Friendly, Guarded, RoutineDeviation) derived from NPC axis values each tick (#90)
- Tolerance threshold monitoring — ToleranceBreachEvent on stress exceeding per-NPC threshold, mood FSM integration (#105)
- Routine deviation detection — RoutineDeviationEvent on wrong location/activity for day phase, absence detection, pathfinding-aware (#243)
- Follow mechanic — Follow verb, proximity/LOS tracking, double-frequency observation events, NPC suspicion accumulation, configurable thresholds (#241)
- Monologue event triggers — observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation with D-035 context tags (#119)
- Protocol v13 — tell_state on VisibleEntity, follow_state on ObserverSnapshot, Follow verb
## [v0.1.14] — 2026-02-21
### Added
- Unified dialogue log — player-NPC and overheard NPC-NPC conversations in one chronological scrolling panel (#535, D-061/D-078)
- F3 debug overlay — real-time game state display with tick, FPS, position, entity counts, dialogue/monologue status (#511)
- Monologue display — multi-line priority queue with character colours, italic BBCode, stagger animation (#122)
- Protocol v9 — conversation_events, conversation_ended, dialogue_response fields with carry-forward logic
- Dialogue theme system — configurable NPC name colour palette, entry timing, passive opacity via dialogue-theme.yaml
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
- Monologue line pool maxLength raised from 160 to 256 chars (soft guidance ≤160)
- NPC name masking infrastructure — entity-anchored dialogue log with server-side role labels, retroactive name update on learning, NpcColorIndex for stable color assignment
- Dialogue option keyboard selection (1/2/3 number keys) and numbered option labels
- Interaction list chrome — background panel, mouse hover highlighting, click-to-interact, pointing hand cursor
### Changed
- D-061 updated to document unified conversation log architecture from Sprint 14
- Dialogue options switched from RichTextLabel to Label for reliable VBoxContainer sizing
### Fixed
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
- BBCode injection in dialogue log formatting — server-sourced strings now escaped with [lb]
- Per-frame dialogue log rebuild replaced with dirty flag (performance)
- dialogue_active lifecycle — now cleared after panel fade completes per D-064
- PAUSE/UNPAUSE routed through main.gd input recording for bug report replay (#507)
- WASD input freeze after filing bug report — LineEdit focus not released before queue_free() across CanvasLayers
- WASD not reactivating after Talk — dialogue_active held for entry_lifetime instead of cleared immediately
- Recognition chime spam — entity IDs now tracked permanently per room instead of expiring
- Audio path warning — res://audio/ corrected to res://assets/audio/ in AudioManager
- world_radial.tscn anchors_preset warning — changed from 15 to 0
- bug_report_dialog.gd push_warning changed to print for informational message
## [v0.1.13] — 2026-02-20
### Added
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
## [v0.1.12] — 2026-02-19
### Added
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
- Information tag schema (#138), component-level access control (#139)
- Line previewer CLI (#193)
- Sound event system — server pipeline (#124)
- Close-range stereo audio — client positional 2D (#125)
- Medium-range visual indicators — fog-edge directional arrows (#126)
- HashMap ban in simulation crate via clippy (#343)
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
- v0.1 Visual Grammar Document (#303)
- Placeholder art specification (#252)
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
- Sova Station Profile — 6 districts, governance, off-station references (#320)
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
- Divergent starting knowledge and relationships per character (#182, #183)
- Detective institutional chain of command (#322)
- Contradiction arc design document — reusable FRIEND pattern (#332)
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
- First 5 minutes experience design — systemic opening per character (#259)
- Opening hook content per character (#260)
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
- Knowledge state vocabulary — author-facing quick reference (#309)
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
- Diegetic insert flavor text — per-character labels and notification strings (#331)
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
### Fixed
- Entity renderer field name bug — `id` vs `entity_id` (#345)
- Dialogue max-width pixel value — 640px per D-076 (#447)
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
- Stale protocol version doc comment "Current: 9" corrected to 10
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
### Changed
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
- Observer test setup now inserts SoundEventQueue resource for integration coverage
- Added FactionOnly positive test case and Medium-range occlusion TODO
- Sound indicator colors sourced from Constants instead of duplicated hex literals
- play_loop() null guard on stream.duplicate()
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
## [v0.1.11] — 2026-02-19
### Added
+9 -1
View File
@@ -20,6 +20,7 @@ docs/
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/
@@ -35,7 +36,7 @@ db/
decisions/ # Decision domain files (source of truth)
README.md # Domain index and query examples
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
process.md # D-004, D-021, D-022
@@ -132,12 +133,19 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
Key rules:
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
- **Use `--output simple`** for machine-readable output (no table borders)
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
```bash
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
# Step 2: Post via cat
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
```
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
+22 -5
View File
@@ -1,13 +1,13 @@
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \
.PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline
perf-baseline debug-schedule
# --- Configuration ---
@@ -28,6 +28,7 @@ help:
@echo " make ci Run full CI pipeline locally"
@echo " make ci-client Run client CI checks"
@echo " make ci-server Run server CI checks"
@echo " make check-protocol Verify server/client protocol versions match"
@echo " make clean Remove build artifacts and caches"
@echo ""
@echo " make db-backup Backup shared database to git (main only)"
@@ -53,6 +54,7 @@ help:
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
@echo ""
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -81,7 +83,16 @@ setup-hooks:
# --- Build ---
build: build-server build-client
check-protocol:
@SERVER_V=$$(grep 'pub const PROTOCOL_VERSION' server/src/bridge/types.rs | sed 's/.*= *//;s/[^0-9]//g'); \
CLIENT_V=$$(grep 'const PROTOCOL_VERSION' client/scripts/protocol/protocol.gd | sed 's/.*= *//;s/[^0-9]//g'); \
if [ "$$SERVER_V" != "$$CLIENT_V" ]; then \
echo "ERROR: Protocol version mismatch — server=$$SERVER_V, client=$$CLIENT_V"; \
echo " Fix: update client/scripts/protocol/protocol.gd to match server/src/bridge/types.rs"; \
exit 1; \
fi
build: check-protocol build-server build-client
build-server:
cd server && cargo build
@@ -93,7 +104,7 @@ build-client:
# --- Run ---
server:
cd server && cargo run
cd server && cargo run --bin settled-reach-server
client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@@ -102,7 +113,7 @@ client:
game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@cd server && cargo run --bin settled-reach-server &
@sleep 2
@echo "Starting client..."
@SR_LIVE=1 $(GODOT) --path client
@@ -285,6 +296,12 @@ checklist-generate:
perf-baseline:
@tooling/perf-baseline
# --- Schedule debug (#346) ---
debug-schedule:
@echo "Dumping bevy_ecs schedule graph..."
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dfy0ye3srawos"
path="res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_bar_layer.ogg"
dest_files=["res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cne005dbmwt6d"
path="res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_corridor_layer.ogg"
dest_files=["res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b6ycjdvxpphfa"
path="res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_station_base.ogg"
dest_files=["res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dw2pokvg5d7v2"
path="res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"
[deps]
source_file="res://assets/audio/amb_workplace_layer.ogg"
dest_files=["res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://da81bx5y6iw87"
path="res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_footstep_metal_run.ogg"
dest_files=["res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://d14dx3q0qd483"
path="res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_footstep_metal_walk.ogg"
dest_files=["res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cow7symyvpmal"
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_npc_murmur.ogg"
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://qbg8rfnetlqn"
path="res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"
[deps]
source_file="res://assets/fonts/Michroma-Regular.ttf"
dest_files=["res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+47
View File
@@ -0,0 +1,47 @@
# Dialogue Log Theme — The Settled Reach
#
# Ticket: #535 | Sprint: 14
# Colors and timing for the unified dialogue log panel.
# Loaded by dialogue_box.gd at runtime.
# ============================================================
# PLAYER COLOR
# Fixed color for the player's name in dialogue log entries.
# ============================================================
player_color: "#e0e8ff"
# ============================================================
# NPC COLOR PALETTE
# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8.
# Must be readable on a dark semi-transparent panel background.
# ============================================================
npc_colors:
0: "#4a9ebb" # teal
1: "#6bc9a6" # green
2: "#e8c547" # amber
3: "#d49e5d" # warm orange
4: "#b586d4" # lavender
5: "#d45d5d" # muted red
6: "#5daa7d" # forest
7: "#7daccc" # sky blue
# ============================================================
# TEXT COLORS
# Arrow separator and speech text color.
# ============================================================
arrow_color: "#8890a0"
speech_color: "#c8d0e0"
# ============================================================
# PASSIVE (OVERHEARD) OPACITY
# Base opacity multiplier for non-player-centric lines.
# 1.0 = full opacity, 0.0 = invisible.
# ============================================================
passive_opacity: 0.9
# ============================================================
# ENTRY TIMING
# All entry types share the same lifetime and fade duration.
# ============================================================
entry_lifetime_seconds: 45.0
entry_fade_seconds: 5.0
+43
View File
@@ -0,0 +1,43 @@
[gd_resource type="AudioBusLayout" format=3]
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
[resource]
bus/0/name = "Master"
bus/0/solo = false
bus/0/mute = false
bus/0/bypass_fx = false
bus/0/volume_db = 0.0
bus/0/send = &""
bus/1/name = "Music"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
bus/2/name = "Ambient"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = &"Master"
bus/3/name = "WorldSFX"
bus/3/solo = false
bus/3/mute = false
bus/3/bypass_fx = false
bus/3/volume_db = 0.0
bus/3/send = &"Master"
bus/4/name = "PlayerActions"
bus/4/solo = false
bus/4/mute = false
bus/4/bypass_fx = false
bus/4/volume_db = 0.0
bus/4/send = &"Master"
bus/5/name = "UISounds"
bus/5/solo = false
bus/5/mute = false
bus/5/bypass_fx = false
bus/5/volume_db = 0.0
bus/5/send = &"Master"
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#1a1a2e"/></svg>

After

Width:  |  Height:  |  Size: 119 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://caxeq5xayr0iy"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+9
View File
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[audio]
buses/default_bus_layout="res://default_bus_layout.tres"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
@@ -116,6 +120,11 @@ bug_report={
"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":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_overlay={
"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":4194334,"key_label":0,"unicode":0,"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)
+24 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -16,9 +16,12 @@
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -96,6 +99,13 @@ script = ExtResource("4_fog")
z_index = 950
script = ExtResource("16_fogent")
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
; Directional arrows at fog boundary for sounds outside LOS.
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
[node name="SoundIndicators" type="Node2D" parent="World"]
z_index = 951
script = ExtResource("20_soundind")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
@@ -143,6 +153,16 @@ layer = 20
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
; #511: F3 debug overlay — real-time game state, toggled by F3
[node name="DebugOverlay" type="Control" parent="UILayer"]
anchors_preset = 0
offset_left = 16
offset_top = 120
offset_right = 400
offset_bottom = 400
mouse_filter = 2
script = ExtResource("22_debug")
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
script = ExtResource("10_cursor")
@@ -154,3 +174,6 @@ layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
+132 -8
View File
@@ -5,6 +5,11 @@ extends Node
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
# --- D-067: Recognition chime asset key ---
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
const CHIME_RECOGNITION := "sfx_monologue_chime"
# --- Bus names (D-068) ---
const BUS_MUSIC := "Music"
const BUS_AMBIENT := "Ambient"
@@ -36,6 +41,23 @@ const DIP_SPECS := {
# Low-pass filter default cutoff — effectively bypassed at this value.
const FILTER_CUTOFF_DEFAULT := 20500.0
# --- D-073: Zone crossfade constants ---
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
# Maps server zone_id strings to ambient asset keys (filenames in res://assets/audio/).
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
# they are the same location type, so hub→workplace transition is a same-asset no-op
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
const ZONE_ASSETS: Dictionary = {
"hub": "amb_hub_layer",
"workplace": "amb_hub_layer",
"bar": "amb_bar_layer",
"corridor": "amb_corridor_layer",
}
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
@@ -52,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
var _ambient_players: Dictionary = {}
# D-073: Zone crossfade state
var _current_zone_id: String = ""
var _zone_tweens: Array = []
signal dip_changed(profile: String)
const PREFS_PATH := "user://audio_prefs.cfg"
func _ready() -> void:
_setup_buses()
_scan_registry()
_load_prefs()
# --- Bus setup ---
@@ -83,15 +112,15 @@ func _setup_buses() -> void:
# --- Asset registry (D-068 directory-scan pattern) ---
func _scan_registry() -> void:
_scan_dir("res://audio/")
_scan_dir("res://assets/audio/")
print("AudioManager: %d assets registered" % _registry.size())
func _scan_dir(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
if path == "res://audio/":
print("AudioManager: res://audio/ not found — all play methods no-op")
if path == "res://assets/audio/":
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
return
dir.list_dir_begin()
var file_name := dir.get_next()
@@ -105,6 +134,7 @@ func _scan_dir(path: String) -> void:
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
dir.list_dir_end()
func has_asset(asset_key: String) -> bool:
@@ -137,6 +167,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye
return null
stop_loop(asset_key)
var loop_stream := stream.duplicate() as AudioStream
if loop_stream == null:
return null
_enable_loop(loop_stream)
var player := AudioStreamPlayer.new()
player.stream = loop_stream
@@ -163,6 +195,32 @@ func stop_all_loops() -> void:
stop_loop(key)
# --- Audio asset registry: event type → asset key (D-018, #125) ---
# Maps server-sent sound event_type strings to audio asset keys.
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = {
"Footstep": "sfx_footstep_metal_walk",
"FootstepWalk": "sfx_footstep_metal_walk",
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepSprint": "sfx_footstep_metal_run",
"FootstepRun": "sfx_footstep_metal_run",
}
## Play a close-range sound event at a world tile position (D-018, #125).
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
## world_tile_pos: server tile coordinates — converted to world pixels internally.
## No-ops if event_type has no registered asset or asset file is absent.
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
if asset_key.is_empty():
return
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
# --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels).
@@ -270,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
var buses: Dictionary = spec.get("buses", {})
var offset_db: float = buses.get(bus, 0.0)
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
_save_prefs()
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Zone crossfade (D-073 stub) ---
# --- Volume persistence (user://audio_prefs.cfg) ---
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
for bus_name in BUSES:
if cfg.has_section_key("audio", bus_name):
var db: float = cfg.get_value("audio", bus_name, 0.0)
# Apply directly: bypass _save_prefs() on initial load.
_bus_volumes[bus_name] = db
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
for bus_name in BUSES:
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
# --- Zone crossfade (D-073) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Full crossfade implementation deferred to Sprint 9+ (D-073).
## Stub exists so server integration can call it without conditional checks.
func set_zone(_zone_id: String) -> void:
pass
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
## Interruptible — mid-crossfade zone change tweens from current position.
## No-op if assets absent (D-038) or same zone.
func set_zone(zone_id: String) -> void:
if zone_id == _current_zone_id:
return
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
_current_zone_id = zone_id
_kill_zone_tweens()
# Fade out old ambient layer (if different asset from incoming zone)
if not old_asset.is_empty() and old_asset != new_asset:
if _ambient_players.has(old_asset):
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
if is_instance_valid(old_player):
var tween := create_tween()
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
tween.tween_callback(stop_loop.bind(old_asset))
_zone_tweens.append(tween)
# Fade in new ambient layer
if not new_asset.is_empty():
if _ambient_players.has(new_asset):
# Already playing (interrupted reverse crossfade) — tween from current volume
var existing: AudioStreamPlayer = _ambient_players[new_asset]
if is_instance_valid(existing):
var tween := create_tween()
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
elif has_asset(new_asset):
var new_player := play_loop(new_asset, BUS_AMBIENT)
if new_player:
new_player.volume_db = -80.0
var tween := create_tween()
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
_zone_tweens.append(tween)
func _kill_zone_tweens() -> void:
for tween in _zone_tweens:
if tween != null and tween.is_valid():
tween.kill()
_zone_tweens.clear()
# --- Internal helpers ---
+95 -1
View File
@@ -19,11 +19,20 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
# refined when the server assigns explicit player entity IDs).
var player_entity_id: int = 1
# #241: Follow target — entity_id of the NPC the player is following, -1 when not following.
# Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior.
var follow_target_id: int = -1
# v4 fields (#404/#405)
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds} or null
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
# Server sends this field as part of the player's capability snapshot.
var lattice_profile: String = "lattice_baseline"
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
@@ -54,6 +63,32 @@ var rng_seed: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
# v8 fields (#305, D-028): NPC follow-up after player dialogue choice
var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
var conversation_ended: Array = [] # [{speaker_id, target_id}]
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = []
# #125, D-018: Close-range sound events for positional 2D audio.
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -75,6 +110,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
if snapshot.has("tiles"):
visible_tiles = snapshot.tiles
@@ -111,6 +154,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
current_monologue = null
# #122: lattice_profile — character insert capability level for monologue colour
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
player_stance = snapshot.player_stance
@@ -133,6 +180,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
pending_recognitions = []
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
conversation_events = snapshot.conversation_events
else:
conversation_events = []
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
conversation_ended = snapshot.conversation_ended
else:
conversation_ended = []
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
dialogue_response = snapshot.dialogue_response
else:
dialogue_response = null
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
gauntlet_mode = true
@@ -157,6 +222,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
rng_seed = null
# D-018: Sound events from server — partition by range_category.
# #126: Medium → fog-edge directional indicators.
# #125: Close → positional 2D audio via AudioManager.
if snapshot.has("sound_events") and snapshot.sound_events is Array:
medium_sound_events = []
close_sound_events = []
for se in snapshot.sound_events:
if not se is Dictionary:
continue
var rc: String = se.get("range_category", "")
if rc == "Medium":
medium_sound_events.append(se)
elif rc == "Close":
close_sound_events.append(se)
else:
medium_sound_events = []
close_sound_events = []
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
# positional indexing — use the same tile iteration below instead.
current_zone_id = ""
var _px := int(player_position.x)
var _py := int(player_position.y)
for _ztile in visible_tiles:
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
current_zone_id = _ztile.get("zone_id", "")
break
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+44
View File
@@ -241,6 +241,17 @@ func receive_bytes(bytes: PackedByteArray) -> void:
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
if old_conv_events.size() > 0:
var new_conv_events: Array = snapshot.get("conversation_events", [])
snapshot["conversation_events"] = old_conv_events + new_conv_events
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
@@ -396,6 +407,37 @@ func _test_snapshot() -> Dictionary:
"total_delay_ticks": total_delay,
})
# #535: Mock overheard NPC-NPC conversation (D-078)
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
# Conversation ends after 6 exchanges (~30 ticks).
var conv_events: Array = []
var conv_ended: Array = []
var conv_start := 3
var conv_lines := [
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
]
var conv_tick_interval := 5
var conv_total_ticks := conv_lines.size() * conv_tick_interval
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
var conv_index := (_test_tick - conv_start) / conv_tick_interval
var within_tick := (_test_tick - conv_start) % conv_tick_interval
if within_tick == 0 and conv_index < conv_lines.size():
var cl: Dictionary = conv_lines[conv_index]
conv_events.append({
"speaker_id": 10,
"target_id": 11,
"speaker_name": cl.speaker,
"target_name": cl.target,
"occluded_line": cl.line,
})
elif _test_tick == conv_start + conv_total_ticks:
conv_ended.append({"speaker_id": 10, "target_id": 11})
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -417,6 +459,8 @@ func _test_snapshot() -> Dictionary:
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
"conversation_events": conv_events,
"conversation_ended": conv_ended,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
@@ -0,0 +1 @@
uid://rqfw0ycyb4c6
+14
View File
@@ -90,6 +90,20 @@ const PERIPHERAL_ALPHA: float = 0.5
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
# Raised from D-076 default (640px) to 1200px for readability.
# Tyre architecture review 2026-02-19: readability over max-width; fits
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
# #117: Camera smoothing speed — exponential interpolation via manual lerp in main.gd.
# Same pattern as EntityRenderer.LERP_SPEED. At 8.0: ~55% convergence after 0.1s.
# Slightly softer than entity movement (12.0) for a touch of cinematic camera lag.
const CAMERA_SMOOTHING_SPEED: float = 8.0
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
+177 -32
View File
@@ -14,26 +14,31 @@ 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 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)
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
func _ready() -> void:
print("The Settled Reach — client initialized")
# Disable camera smoothing during init. Camera2D's position_smoothing
# lerps an internal smoothed_camera_pos toward global_position each frame.
# That smoothed position initializes at (0,0) — the Camera2D's default in
# the .tscn. Even after we set global_position to the player coords,
# smoothing causes the viewport to still show (0,0) on the first rendered
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
# uses global_position directly. Re-enabled in _process() after anchor.
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
# would conflict because we'd be setting global_position to the target every frame.
camera.position_smoothing_enabled = false
# Connect to simulation (test mode sets CONNECTED immediately)
@@ -42,7 +47,7 @@ func _ready() -> void:
# Camera anchor: snap to player position before the first frame renders.
# In test mode poll_snapshot() returns synchronously — position is set
# immediately. In live mode the snapshot isn't available yet — _process
# handles it. No reset_smoothing() needed: smoothing is OFF.
# handles it via the lerp block in _process().
var first_snapshot: Variant = SimBridge.poll_snapshot()
if first_snapshot != null:
GameState.apply_snapshot(first_snapshot)
@@ -54,13 +59,15 @@ func _ready() -> void:
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
@@ -113,6 +120,9 @@ func _process(_delta: float) -> void:
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
@@ -121,29 +131,43 @@ func _process(_delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# Track camera to player position every frame (D-015: locked, no panning)
if _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# Re-enable smoothing after the first anchored frame. The frame that just
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
# #501: Skip re-enable during teleport — _teleport_transition() disables
# smoothing for a clean camera snap. Defer by one frame to avoid the
# re-enable block in the same _process() call undoing the snap.
if _camera_anchored and not camera.position_smoothing_enabled:
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
# Init: camera already snapped in _ready() or late-anchor path above.
if _camera_anchored:
var target := GameState.player_position * Constants.TILE_SIZE
if _teleport_in_progress:
camera.global_position = target
_teleport_in_progress = false
else:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta)
camera.global_position = camera.global_position.lerp(target, weight)
# Send queued input to simulation
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
@@ -155,6 +179,14 @@ func _process(_delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
if settings_dialog.is_open():
settings_dialog.close()
else:
settings_dialog.open()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -191,6 +223,61 @@ func _process(_delta: float) -> void:
_pending_record_inputs.clear()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
# Consume-once: events are cleared after processing so they don't replay if
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
func _play_close_sound_events() -> void:
for evt in GameState.close_sound_events:
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
continue
AudioManager.play_sound_event(
evt.get("event_type", ""),
Vector2(float(evt.x), float(evt.y))
)
GameState.close_sound_events = []
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
# enters the cognitive delay recognition queue for the first time.
# "The chime marks the character's attention shifting" (D-067).
# IDs persist for the session — one chime per entity, no re-trigger on
# fog oscillation or server re-send. Cleared on room change (teleport).
func _play_recognition_chimes() -> void:
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
# (extracted in apply_snapshot(), server-authoritative per D-020).
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
func _update_zone() -> void:
var zone := GameState.current_zone_id
if zone != _current_zone:
_current_zone = zone
AudioManager.set_zone(zone)
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
# Only activates when no other dip (dialogue/confrontation) is running.
# Only deactivates its own dip — never touches dialogue/confrontation.
# D-070: no UI indicator — the boost is "felt, not computed."
func _update_listening_focus() -> void:
var current_dip := AudioManager.get_active_dip()
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
if threshold_met and current_dip == "":
AudioManager.apply_dip("listening_focus")
elif not threshold_met and current_dip == "listening_focus":
AudioManager.clear_dip()
# Consume-once per tick: show monologue text, then clear.
# Tick guard prevents re-triggering when the same tick is polled multiple
# times (client FPS > sim tick rate).
@@ -201,7 +288,12 @@ func _consume_monologue() -> void:
return
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
monologue_display.show_monologue(
mono.get("text", ""),
mono.get("duration_seconds", 5.0),
mono.get("priority", 2),
mono.get("is_urgent", false)
)
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
if mono_id.begins_with("room_reset"):
@@ -209,6 +301,7 @@ func _consume_monologue() -> void:
GameState.current_monologue = null
# Consume-once per tick with ID tracking: show dialogue, then clear.
# Tick guard + is_dialogue_active check prevent re-triggering.
func _consume_dialogue() -> void:
@@ -222,6 +315,7 @@ func _consume_dialogue() -> void:
_last_dialogue_tick = GameState.current_tick
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
dialogue_box.show_dialogue(
dlg.get("npc_name", ""),
dlg.get("speech", ""),
@@ -230,6 +324,39 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
# #535: Consume overheard NPC-NPC conversation events (D-078).
# Each event carries pre-occluded text — render verbatim in the dialogue log.
func _consume_conversation_events() -> void:
if not dialogue_box:
return
for event in GameState.conversation_events:
dialogue_box.append_conversation_event(event)
GameState.conversation_events = []
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
func _consume_conversation_ended() -> void:
if not dialogue_box:
return
for event in GameState.conversation_ended:
dialogue_box.on_conversation_ended(event)
GameState.conversation_ended = []
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
# Updates dialogue_box entity display registry with speaker identity from the wire.
func _consume_dialogue_response() -> void:
if GameState.dialogue_response == null or not dialogue_box:
return
var dr: Dictionary = GameState.dialogue_response
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
var speaker_color_index: int = dr.get("speaker_color_index", -1)
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
GameState.dialogue_response = null
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
@@ -244,9 +371,29 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
func _on_confrontation_monologue(text: String, duration: float) -> void:
if monologue_display:
monologue_display.show_monologue(text, duration)
if not monologue_display:
return
if GameState.current_tick == _last_confrontation_tick:
return
_last_confrontation_tick = GameState.current_tick
monologue_display.show_monologue(text, duration, 3, true)
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
func _on_dialogue_pause_requested() -> void:
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
func _on_dialogue_unpause_requested() -> void:
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# D-064: Handle walk-away → send WalkAway{npc_id} to server
@@ -278,11 +425,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Snap camera: disable smoothing, force re-anchor.
# _teleport_in_progress defers smoothing re-enable by one frame so the
# re-enable block at the bottom of _process() doesn't undo the snap.
camera.position_smoothing_enabled = false
camera.global_position = GameState.player_position * Constants.TILE_SIZE
# Set teleport flag — the camera tracking block in _process() will snap
# to the player's new position this frame (no lerp). Flag clears after snap.
_camera_anchored = true
_teleport_in_progress = true
@@ -290,6 +434,7 @@ func _teleport_transition() -> void:
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
_known_recognition_ids.clear() # D-067: reset chimes for new room
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
+29 -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 = 8
const PROTOCOL_VERSION: int = 13
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
# Each event carries pre-occluded text plus speaker/target attribution.
var conversation_events: Array = []
var raw_conv_events: Variant = raw.get("conversation_events")
if raw_conv_events is Array:
for raw_ce in raw_conv_events:
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
conversation_events.append({
"speaker_id": int(raw_ce.get("speaker_id", 0)),
"target_id": int(raw_ce.get("target_id", 0)),
"speaker_name": str(raw_ce.get("speaker_name", "")),
"target_name": str(raw_ce.get("target_name", "")),
"occluded_line": str(raw_ce["occluded_line"]),
})
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
var conversation_ended: Array = []
var raw_conv_ended: Variant = raw.get("conversation_ended")
if raw_conv_ended is Array:
for raw_end in raw_conv_ended:
if raw_end is Dictionary:
conversation_ended.append({
"speaker_id": int(raw_end.get("speaker_id", 0)),
"target_id": int(raw_end.get("target_id", 0)),
})
return {
"tick": tick,
"entities": entities,
@@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions,
"conversation_events": conversation_events,
"conversation_ended": conversation_ended,
}
+12 -9
View File
@@ -13,8 +13,11 @@ extends Node2D
# color from RelationshipState via the knowledge graph.
const TILE_SIZE: int = Constants.TILE_SIZE
const ENTITY_SIZE: int = 24
const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 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.
# Lerp speed — framerate-independent exponential smoothing.
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
@@ -93,8 +96,8 @@ func update_entities(entities: Array) -> void:
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_SIZE, ENTITY_SIZE)
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
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)
@@ -110,8 +113,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
# Snap to initial position (no lerp on first appearance)
if entity_data.has("x") and entity_data.has("y"):
var target := Vector2(
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
)
entity_node.position = target
_entity_targets[entity_id] = target
@@ -129,8 +132,8 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
if entity_data.has("x") and entity_data.has("y"):
_entity_targets[entity_id] = Vector2(
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
@@ -195,5 +198,5 @@ func _add_facing_indicator(parent_node: Control) -> void:
])
indicator.color = Constants.ENTITY_COLOR_PLAYER
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
parent_node.add_child(indicator)
@@ -0,0 +1,152 @@
class_name SoundIndicatorRenderer
extends Node2D
## Medium-range sound indicators (#126, D-018).
## Renders directional arrows at the fog boundary for sounds outside LOS.
##
## Close-range sounds → 2D positional audio (handled by AudioManager).
## Medium-range sounds → visual arrow at fog edge pointing toward source.
##
## Color per D-018/D-069:
## Neutral #c8d0e0 — footsteps, generic sounds
## Voice #e8c547 — speech, conversation, social sounds
## Danger #d45d5d — alert, gunshot, explosion, threat
##
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
## the last FADE_DURATION seconds. New events are appended each tick;
## expired indicators are removed by _process(). Deduplication prevents
## the same source position from stacking multiple arrows.
const TILE_SIZE: int = Constants.TILE_SIZE
# Visual parameters
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
const FADE_DURATION: float = 0.6 # Fade-out window at end
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
const ARROW_LEN: float = 12.0 # Length from tip to base
# D-018/D-069 colors — sourced from Constants to prevent palette drift
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
# Indicators: [{x, y, event_type, elapsed}]
var _indicators: Array = []
func _process(delta: float) -> void:
if _indicators.is_empty():
return
var i := _indicators.size() - 1
while i >= 0:
_indicators[i].elapsed += delta
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
_indicators.remove_at(i)
i -= 1
queue_redraw()
## Append new medium-range sound events from snapshot.
## events: Array of {x: float, y: float, event_type: String}
## Only Medium range_category events should be passed.
## Deduplicates by tile position — if an indicator already exists at (x,y),
## its timer resets instead of spawning a duplicate arrow.
func update_sound_events(events: Array) -> void:
for evt in events:
if not evt.has("x") or not evt.has("y"):
continue
var ex: float = float(evt.x)
var ey: float = float(evt.y)
# Deduplicate: reset timer if an indicator already exists at this tile
var found := false
for ind in _indicators:
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
ind.elapsed = 0.0
ind.event_type = evt.get("event_type", "")
found = true
break
if not found:
_indicators.append({
"x": ex,
"y": ey,
"event_type": evt.get("event_type", ""),
"elapsed": 0.0,
})
if not _indicators.is_empty():
queue_redraw()
func _draw() -> void:
if _indicators.is_empty():
return
var player_world: Vector2 = GameState.player_position * TILE_SIZE
# Compute half-extents of the visible world area from camera zoom + viewport.
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
# the fog edge and don't clip to the physical screen border.
var vp_size := get_viewport().get_visible_rect().size
var cam := get_viewport().get_camera_2d()
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
var half_extents: Vector2 = vp_size / (2.0 * zoom)
for ind in _indicators:
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
var dir: Vector2 = sound_world - player_world
if dir.is_zero_approx():
continue
dir = dir.normalized()
# Project direction onto the visible-area rectangle boundary
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
var t: float = ind.elapsed / INDICATOR_LIFETIME
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
var alpha: float
if t < fade_start:
alpha = 1.0
else:
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
var color: Color = color_for_type(ind.event_type)
color.a = alpha * 0.9
_draw_arrow(edge_pt, dir, color)
## Project from center along dir to the boundary of a rectangle with
## half_extents, inset by inset pixels. Returns the boundary point.
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
var h := Vector2(
maxf(half.x - inset, 8.0),
maxf(half.y - inset, 8.0),
)
# Ray-AABB slab test: find smallest positive t where the ray exits
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
var t: float = minf(t_x, t_y)
return center + dir * t
## Draw a filled arrowhead at pos pointing in dir.
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := pos - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
## Map event type string → D-018 color category.
func color_for_type(event_type: String) -> Color:
var et := event_type.to_lower()
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
return COLOR_VOICE
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
or et.contains("alert") or et.contains("threat"):
return COLOR_DANGER
return COLOR_NEUTRAL
@@ -0,0 +1 @@
uid://cb10ir0idsr0g
+11
View File
@@ -12,6 +12,7 @@ extends TileMapLayer
# (4,0) = reset_plate — amber (#502)
const TILE_SIZE: int = Constants.TILE_SIZE
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
@@ -64,6 +65,10 @@ func _setup_tileset() -> void:
# Update tiles from snapshot data
# tiles: Array of {x: int, y: int, z: int, type: String}
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
# NOT the Godot scene z_index (which controls render order within a floor).
# This node only renders floor-level 0. Higher floor levels will be handled
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
func update_tiles(tiles: Array) -> void:
if not _initialized:
return
@@ -74,6 +79,12 @@ func update_tiles(tiles: Array) -> void:
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
continue
# Floor-level filter: only render tiles at ground floor.
# Upper floor tiles (level 1+) are for future multi-floor nodes.
var tile_z: int = tile_data.get("z", 0)
if tile_z != GROUND_FLOOR:
continue
var tile_type_str: String = tile_data.type
if not TILE_TYPE_MAP.has(tile_type_str):
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
@@ -16,6 +16,7 @@ extends Node2D
@onready var tile_renderer = $FogGroup/FloorTiles
@onready var fog_renderer = $FogOverlay
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
var _last_tick: int = -1
@@ -42,3 +43,7 @@ func update_from_state() -> void:
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
entity_renderer.update_entities(GameState.visible_entities)
# D-018 #126: Update medium-range sound indicators
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
+115
View File
@@ -0,0 +1,115 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
const VALUE_COLOR := Color("#c8d0e0")
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
var _cached_font: Font = null
func _ready() -> void:
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
return
queue_redraw()
func _draw() -> void:
if not visible:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
left_lines.append(["tick", str(GameState.current_tick)])
right_lines.append(["fps", str(Engine.get_frames_per_second())])
var pos := GameState.player_position
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
right_lines.append(["facing", GameState.player_facing])
left_lines.append(["stance", GameState.player_stance])
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
left_lines.append(["entities", str(GameState.visible_entities.size())])
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
var mono_status := "active" if GameState.current_monologue != null else "idle"
var dlg_status := "active" if GameState.dialogue_active else "idle"
left_lines.append(["monologue", mono_status])
right_lines.append(["dialogue", dlg_status])
left_lines.append(["stationary", str(GameState.stationary_ticks)])
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
var gt := GameState.game_time
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
left_lines.append(["time", time_str])
right_lines.append(["tick_rate", rate_str])
var mode_str := "test" if SimBridge.test_mode else "live"
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
var right_value_w: float = 0.0
for line in left_lines:
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
for line in right_lines:
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
var header_text := "F3 DEBUG"
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
+1
View File
@@ -0,0 +1 @@
uid://c55ow14m345vv
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.
+357
View File
@@ -0,0 +1,357 @@
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
## Spec refs:
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
## D-069: Audio dip profiles — confrontation drops WorldSFX 46dB
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
##
## Test layers:
## 1. D-068 Bus architecture constants (no implementation required)
## 2. D-069 Dip profile spec values (no implementation required)
## 3. AudioManager API — dip state machine, signal emission
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
class_name TestAudioBusRouting
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
func after_test() -> void:
AudioManager.clear_dip()
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
# ==============================================================================
# Layer 1: D-068 Bus Architecture Constants
# ==============================================================================
func test_d068_bus_world_sfx_name() -> void:
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d068_bus_ambient_name() -> void:
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
func test_d068_bus_player_actions_name() -> void:
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
func test_d068_bus_ui_sounds_name() -> void:
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
func test_d068_bus_music_name() -> void:
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
func test_d068_five_buses_defined() -> void:
## D-068: Exactly 5 buses in the architecture.
assert_that(AudioManager.BUSES.size()).is_equal(5)
func test_d068_all_bus_names_present_in_buses_array() -> void:
## D-068: BUSES array must contain all 5 named buses.
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
func test_d068_bus_names_are_unique() -> void:
## D-068: No two buses share a name.
var seen: Dictionary = {}
for bus_name in AudioManager.BUSES:
assert_that(seen.has(bus_name)).is_false()
seen[bus_name] = true
# ==============================================================================
# Layer 2: D-069 Dip Profile Spec Values
# Close-range sound events go on WorldSFX; confrontation dips that bus 46 dB.
# ==============================================================================
func test_d069_three_dip_profiles_exist() -> void:
## D-069: Three profiles — dialogue, confrontation, listening_focus.
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
## D-069: Confrontation must drop WorldSFX by 46 dB. Sprint 12 uses -5.0 dB.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
var dip_db: float = buses["WorldSFX"]
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
func test_d069_confrontation_dips_ambient() -> void:
## D-069: Confrontation suppresses both Ambient and WorldSFX.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("Ambient")).is_true()
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
assert_that(spec.has("filter_hz")).is_true()
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
## Confrontation is the one that mutes world SFX.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
func test_d069_listening_focus_boosts_world_sfx() -> void:
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("WorldSFX")).is_true()
assert_that(buses["WorldSFX"] > 0.0).is_true()
func test_d069_all_profiles_have_positive_ease_times() -> void:
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
for profile in AudioManager.DIP_SPECS.keys():
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
assert_that(spec.has("ease_in")).is_true()
assert_that(spec.has("ease_out")).is_true()
assert_that(spec["ease_in"] > 0.0).is_true()
assert_that(spec["ease_out"] > 0.0).is_true()
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(conf_out >= dial_out).is_true()
# ==============================================================================
# Layer 2b: D-067 Recognition Chime
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
# ==============================================================================
func test_d067_chime_recognition_constant_exists() -> void:
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
## "Neural lattice firing" feel — soft crystalline tone.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
## No further assertion needed — play() default bus IS UISounds by design.
func test_d067_chime_noop_when_asset_absent() -> void:
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
## play(CHIME_RECOGNITION) must be a no-op (no crash).
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 3: AudioManager API — Dip State Machine
# ==============================================================================
func test_audio_manager_initial_state_no_active_dip() -> void:
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_sets_active() -> void:
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_audio_manager_clear_dip_resets_active() -> void:
AudioManager.apply_dip("confrontation")
AudioManager.clear_dip()
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
## Unknown profile: push_warning + early return, active dip stays empty.
AudioManager.apply_dip("not_a_real_profile")
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
## apply_dip() must emit dip_changed(profile) synchronously.
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
## so a mutable reference type is required to observe signal argument inside the closure.
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("dialogue")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("dialogue")
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
## clear_dip() must emit dip_changed("") to signal audio restored.
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := ["sentinel"]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.clear_dip()
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("")
func test_audio_manager_apply_dip_interrupts_previous() -> void:
## Switching profiles mid-dip: active profile must update to the new one.
AudioManager.apply_dip("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
## Switching from dialogue to confrontation emits dip_changed("confrontation").
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
AudioManager.apply_dip("dialogue")
var received := [""]
var conn := func(p: String) -> void: received[0] = p
AudioManager.dip_changed.connect(conn)
AudioManager.apply_dip("confrontation")
AudioManager.dip_changed.disconnect(conn)
assert_that(received[0]).is_equal("confrontation")
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
## has_asset() must return false for a key that was never registered.
## Ensures no-op fallback path (D-038) is correctly guarded.
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
# ==============================================================================
# Layer 4: D-018 Snapshot Integration — #125 implemented
# GameState partitions sound_events by range_category into close_sound_events
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
# ==============================================================================
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
## #125: Close-range events must land in GameState.close_sound_events.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
## #126/#125: Medium-range events land in medium_sound_events, not close.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(0)
func test_snapshot_partitions_close_and_medium_events() -> void:
## D-018: Mixed batch — Close and Medium events partitioned correctly.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events.size()).is_equal(1)
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
## When snapshot has no sound_events, both close and medium arrays are cleared.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
})
assert_that(GameState.close_sound_events.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.close_sound_events.size()).is_equal(0)
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# ==============================================================================
# Layer 5: AudioManager.play_sound_event — asset registry and routing
# ==============================================================================
func test_audio_manager_sound_event_assets_registry_exists() -> void:
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
## #125 / D-038: Unknown event types must be silently skipped (no-op).
## play_sound_event should not crash and should not emit sound.
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
## #125: Empty event type string must be a no-op.
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
# No assertion needed — absence of crash is the test.
# ==============================================================================
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
# ==============================================================================
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
func test_d071_stationary_ticks_resets_on_movement() -> void:
## D-071: stationary_ticks must reset to 0 when the player position changes.
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
assert_that(GameState.stationary_ticks).is_equal(1)
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
assert_that(GameState.stationary_ticks).is_equal(0)
func test_d071_stationary_ticks_reaches_threshold() -> void:
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
for tick in range(31):
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
assert_that(GameState.stationary_ticks >= 30).is_true()
# ==============================================================================
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
# ==============================================================================
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
AudioManager.apply_dip("confrontation")
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
func test_d069_listening_focus_cleared_by_dialogue() -> void:
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
AudioManager.apply_dip("listening_focus")
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
AudioManager.apply_dip("dialogue")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
func test_d069_clear_dip_noop_when_empty() -> void:
## D-069: clear_dip() when no dip active must be a safe no-op.
AudioManager.clear_dip() # Should not crash
assert_that(AudioManager.get_active_dip()).is_equal("")
@@ -0,0 +1 @@
uid://duvqpvheku2j2
+634
View File
@@ -0,0 +1,634 @@
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
##
## Spec refs:
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
##
## Tickets covered:
## #529 — Zone crossfade implementation (set_zone body + tween timing)
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
## #531 — Recognition chime fires at cognitive delay onset
## #533 — NPC conversation murmur wired to WorldSFX bus
##
## Test layers:
## 1. Zone crossfade spec and API (D-073 / #529)
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
## 3. Volume slider proportional dip (D-068 / D-069)
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
## 5. Recognition chime onset verification (D-067 / #531)
## 6. NPC murmur routing (D-072 / #533)
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
class_name TestAudioSprint13
extends GdUnitTestSuite
func before_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
func after_test() -> void:
AudioManager.clear_dip()
AudioManager.stop_all_loops()
# ==============================================================================
# Layer 1: Zone Crossfade — D-073 / #529
# set_zone() stub exists now; full implementation lands in #529.
# ==============================================================================
func test_d073_set_zone_method_exists() -> void:
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
assert_that(AudioManager.has_method("set_zone")).is_true()
func test_d073_set_zone_hub_does_not_crash() -> void:
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
AudioManager.set_zone("hub")
func test_d073_set_zone_bar_does_not_crash() -> void:
## D-073: set_zone("bar") is a safe call.
AudioManager.set_zone("bar")
func test_d073_set_zone_corridor_does_not_crash() -> void:
## D-073: set_zone("corridor") is a safe call.
AudioManager.set_zone("corridor")
func test_d073_set_zone_empty_string_does_not_crash() -> void:
## D-073: set_zone("") edge case — no zone ID. Must not crash.
AudioManager.set_zone("")
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
AudioManager.set_zone("nonexistent_zone_xyz")
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
## Test verifies naming convention is documentable. Activates once #529 adds the map.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("hub")).is_true()
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
## D-073 / #529: bar → amb_bar_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("bar")).is_true()
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
## D-073 / #529: smuggling corridor → amb_corridor_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("corridor")).is_true()
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
## scan in main.gd per D-020 server-authoritative state.
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
],
})
assert_that(GameState.current_zone_id).is_equal("bar")
GameState.apply_snapshot({"tick": 2})
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
GameState.apply_snapshot({
"tick": 1,
"entities": [player],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
})
assert_that(GameState.current_zone_id).is_equal("")
GameState.apply_snapshot({"tick": 2})
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
## D-073: Crossfade tween duration must be 1.5-2.0s.
## Activates once #529 defines the duration constant.
if not "CROSSFADE_DURATION" in AudioManager:
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
return
var duration: float = AudioManager.CROSSFADE_DURATION
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
## D-073: Crossing back to the current zone should not restart a crossfade.
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
return
AudioManager.set_zone("hub")
AudioManager.set_zone("hub")
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
func test_d073_rapid_zone_crossing_interruptible() -> void:
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
## volume — new tween starts from current position. No stacked tweens, no crash.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
return
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
AudioManager.set_zone("hub")
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
## Zone tweens array should only contain active tweens from the last set_zone call.
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
AudioManager.stop_all_loops()
func test_d068_load_prefs_persistence_roundtrip() -> void:
## #528: Volume persistence — save/load roundtrip via ConfigFile.
## Sets non-default volumes, saves, reloads, and verifies values match.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
## _save_prefs() fires inside set_volume() — prefs file is written.
## Reload prefs by calling _load_prefs() directly.
AudioManager._load_prefs()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
## Reset to defaults for subsequent tests.
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
## #528: _load_prefs() with a missing/corrupted config file must not crash.
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
## This test documents the graceful fallback behavior.
AudioManager._load_prefs()
## No assertion — absence of crash is the test.
# ==============================================================================
# Layer 2: D-069 Dip Timing and dB Spec Values
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
# ==============================================================================
func test_d069_dialogue_ease_in_is_300ms() -> void:
## D-069: Dialogue dip ease-in = 300ms (0.3s).
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.3, 0.05)
func test_d069_dialogue_ease_out_is_500ms() -> void:
## D-069: Dialogue dip ease-out = 500ms (0.5s).
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
assert_that(ease_out).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_in_is_500ms() -> void:
## D-069: Confrontation dip ease-in = 500ms (0.5s).
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
assert_that(ease_in).is_equal_approx(0.5, 0.05)
func test_d069_confrontation_ease_out_is_1000ms() -> void:
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
assert_that(ease_out).is_equal_approx(1.0, 0.05)
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
## must NOT appear in the spec — world events remain audible during conversation.
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
assert_that(buses.has("Ambient")).is_true()
assert_that(buses.has("WorldSFX")).is_false()
assert_that(buses.has("PlayerActions")).is_false()
assert_that(buses.has("UISounds")).is_false()
assert_that(buses.has("Music")).is_false()
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("PlayerActions")).is_false()
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
assert_that(buses.has("UISounds")).is_false()
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
assert_that(buses.has("Ambient")).is_false()
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
func test_d069_confrontation_filter_hz_is_800hz() -> void:
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
# ==============================================================================
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
# ==============================================================================
func test_d068_set_volume_get_volume_roundtrip() -> void:
## D-068: set_volume / get_volume roundtrip preserves the slider value.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
func test_d068_set_volume_persists_across_all_buses() -> void:
## D-068: Each of the 5 buses has an independent volume setting.
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
## D-069: get_volume() always returns the player slider setting (base).
## The effective AudioServer volume during a dip = base + offset_db.
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
## Even during dip, get_volume returns the base (not base + dip offset).
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
func test_d069_set_volume_during_active_dip_updates_base() -> void:
## D-069: Changing slider mid-dip must update the base so the proportional
## calculation uses the new slider value (not the pre-dip value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
AudioManager.apply_dip("dialogue")
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
## must still reflect the player slider setting (not the dipped value).
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
AudioManager.apply_dip("dialogue")
AudioManager.clear_dip()
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
# ==============================================================================
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
#
# These tests verify GameState has the snapshot fields needed for #530 wiring.
# The assertions on AudioManager dip activation are stubbed pending implementation.
# ==============================================================================
func test_d069_game_state_current_dialogue_field_exists() -> void:
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
## Field must exist so #530 wiring can check it.
assert_that("current_dialogue" in GameState).is_true()
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {
"npc_name": "Kael", "npc_entity_id": 2,
"speech": "Haven't seen you around.", "options": [],
},
})
assert_that(GameState.current_dialogue != null).is_true()
assert_that(GameState.current_dialogue is Dictionary).is_true()
GameState.apply_snapshot({"tick": 2})
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_dialogue == null).is_true()
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
AudioManager.clear_dip()
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
## Precondition: dialogue IS active in GameState.
assert_that(GameState.current_dialogue != null).is_true()
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
GameState.apply_snapshot({"tick": 2})
AudioManager.clear_dip()
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
## TODO(#530): Uncomment the assertion once call site is wired.
GameState.apply_snapshot({
"tick": 1,
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
})
GameState.apply_snapshot({"tick": 2}) # dialogue ends
## ASSERTION (activate once #530 is implemented):
## assert_that(AudioManager.get_active_dip()).is_equal("")
assert_that(GameState.current_dialogue == null).is_true()
func test_d071_listening_focus_gate_is_30_ticks() -> void:
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
## The tick gate is the CALLER's responsibility per AudioManager comment.
## This test documents the threshold so it doesn't silently drift.
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
const LISTENING_FOCUS_TICK_GATE := 30
## Stub: verify the spec value is documented.
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
## Verify AudioManager does not expose a confrontation_ui_shown signal.
var signals: Array = AudioManager.get_signal_list().map(
func(s: Dictionary) -> String: return s.name
)
assert_that(signals.has("confrontation_ui_shown")).is_false()
assert_that(signals.has("listening_focus_shown")).is_false()
# ==============================================================================
# Layer 5: Recognition Chime Onset — D-067 / #531
#
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
# pending_recognitions), NOT at completion (when it leaves).
# ==============================================================================
func test_d067_chime_recognition_constant_defined() -> void:
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
## Key must match filename stem in res://audio/.
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
## Verify play() default bus is UISounds (the chime caller uses the default).
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
## Client must not crash when audio branch has not yet provided the .ogg file.
AudioManager.play(AudioManager.CHIME_RECOGNITION)
## No assertion needed — absence of crash is the test.
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
## D-067: "Onset" of cognitive delay = first frame an entity appears in
## pending_recognitions, at remaining_ticks == total_delay_ticks.
## This is the moment the chime must fire.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
var rec: Dictionary = GameState.pending_recognitions[0]
## Onset condition: remaining == total (delay just started)
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
GameState.apply_snapshot({"tick": 2})
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
## The chime must NOT fire at this point — it fired at onset.
GameState.apply_snapshot({
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 1, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
## Completion: entity removed from array
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
## Chime state: AudioManager must not have a dip triggered by recognition.
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
assert_that(AudioManager.get_active_dip()).is_equal("")
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
## This test verifies FogEntities correctly identifies the onset condition.
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6}
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 3, "total_delay_ticks": 6}
## Frame 1: entity APPEARS (onset — chime should fire here)
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
## Frame 2: entity mid-progress (chime must NOT re-fire)
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
## Frame 3: entity completes (chime must NOT fire)
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
assert_that(GameState.pending_recognitions.size()).is_equal(0)
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
## This test documents the spec range so asset authoring can be validated.
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
## return a value in this range.
const CHIME_MIN_DURATION := 0.3
const CHIME_MAX_DURATION := 0.4
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
return
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
if stream == null:
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
return
assert_that(stream.get_length() >= CHIME_MIN_DURATION
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
# ==============================================================================
# Layer 6: NPC Murmur Routing — D-072 / #533
# Single universal asset, WorldSFX bus, no zone-specific variants.
# ==============================================================================
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
## by that bus's noise floor relative to the murmur volume.
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
func test_d072_play_at_method_accepts_bus_parameter() -> void:
## D-072 / #533: play_at() must accept an optional bus string parameter.
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
assert_that(AudioManager.has_method("play_at")).is_true()
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_play_noop_when_murmur_asset_absent() -> void:
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
## No assertion — absence of crash is the test.
func test_d072_no_zone_specific_murmur_variants() -> void:
## D-072: SINGLE universal murmur asset — no zone-specific variants.
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
func test_d072_murmur_does_not_use_ambient_bus() -> void:
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
## Verify bus constant distinction.
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
# ==============================================================================
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
# ==============================================================================
func test_d068_registry_size_is_non_negative() -> void:
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
assert_that(AudioManager.get_registry_size() >= 0).is_true()
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
assert_that(result == null).is_true()
func test_d068_stop_loop_noop_for_unknown_key() -> void:
## D-068: stop_loop() on a key never started — no crash, no error.
AudioManager.stop_loop("nonexistent_key_xyzabc")
func test_d068_stop_all_loops_when_none_playing() -> void:
## D-068: stop_all_loops() with no active ambient players — no crash.
AudioManager.stop_all_loops()
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
func test_d068_play_noop_does_not_change_dip_state() -> void:
## D-068: play() on a missing asset must not modify the dip state machine.
## Verifies no-op fallback has zero side effects.
AudioManager.apply_dip("dialogue")
AudioManager.play("nonexistent_asset_xyzabc")
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
+1
View File
@@ -0,0 +1 @@
uid://d30by4d62g7ts
@@ -0,0 +1 @@
uid://b1tata3wjyb46
+22 -19
View File
@@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void:
assert_that(camera.position_smoothing_enabled).is_false()
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_false()
# --- Camera behavior across frames ---
func test_camera_tracks_player_after_process() -> void:
@@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void:
assert_that(camera.global_position).is_equal(expected)
func test_camera_smoothing_reenabled_after_process() -> void:
# After the first anchored frame, smoothing should be back on for gameplay.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_true()
func test_camera_follows_player_movement() -> void:
func test_camera_lerps_toward_player_movement() -> void:
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
# After one 16ms frame the camera should be partway between old and new position.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var initial_pos := camera.global_position
var initial_pos := camera.global_position # anchored at (320, 320)
# Move player north via SimBridge test mode
SimBridge._test_input_queue.append("MoveNorth")
_instance._process(0.016)
# Camera should have moved with the player
assert_that(camera.global_position.y < initial_pos.y).is_true()
assert_that(camera.global_position).is_equal(
GameState.player_position * Constants.TILE_SIZE)
var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288)
# Camera should have moved north (lower y) but NOT reached the target yet
assert_that(camera.global_position.y).is_less(initial_pos.y)
assert_that(camera.global_position.y).is_greater(new_target.y)
+1
View File
@@ -0,0 +1 @@
uid://dhgcekc2ywr26
+8 -8
View File
@@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void:
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected := Vector2(
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
)
assert_that(node.position).override_failure_message(
"Entity should snap to position on first appear (no lerp)"
@@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void:
renderer._process(0.016)
var after_pos: Vector2 = node.position
var target := Vector2(
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
)
# Position should have moved toward target (x increased)
assert_that(after_pos.x > start_pos.x).override_failure_message(
@@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void:
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
var target := Vector2(
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
)
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
for i in 20:
@@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void:
var small_progress: float = small_node.position.x - small_start
# Reset position for large delta test
small_node.position = Vector2(
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
)
# Large delta step
var large_start: float = small_node.position.x
+1
View File
@@ -0,0 +1 @@
uid://qv808kduvlbk
+1
View File
@@ -0,0 +1 @@
uid://dmalit26eiwlh
@@ -0,0 +1 @@
uid://6q82kiijx7ml
@@ -0,0 +1 @@
uid://c8ng0ubdfumht
+490
View File
@@ -0,0 +1,490 @@
## #122: Monologue display — client tests (Sprint 14)
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
##
## API per Tyre architecture review:
## show_monologue(text, duration, priority=2, is_urgent=false)
## GameState.lattice_profile selects colour palette
class_name TestMonologueDisplay
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _make_display() -> Node:
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
push_warning("TestMonologueDisplay: scene not found — tests skipped")
return null
var node = load("res://ui/monologue_display.tscn").instantiate()
add_child(node)
return node
func _label_text(d: Node) -> String:
if d._visible.is_empty():
return ""
var slot_node: Node = d._visible[0].node
return (slot_node.get_child(0) as RichTextLabel).text
# ---------------------------------------------------------------------------
# Lifecycle — ensure clean GameState between every test
# ---------------------------------------------------------------------------
func before_test() -> void:
## Reset GameState fields touched by this suite so tests don't bleed into each other.
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
## current_monologue: GameState integration tests need null as start state.
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
func after_test() -> void:
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
# ---------------------------------------------------------------------------
# Initial state
# ---------------------------------------------------------------------------
func test_nothing_visible_on_init() -> void:
var d = _make_display()
if d == null: return
assert_int(d._visible.size()).is_equal(0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_stagger_timer_zero_on_init() -> void:
var d = _make_display()
if d == null: return
assert_float(d._next_fade_in_msec).is_equal(0.0)
d.queue_free()
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
# Empty text must be ignored — no visible slot created, no queue entry.
# Prevents ghost display nodes and stagger timer contamination.
var d = _make_display()
if d == null: return
d.show_monologue("", 5.0)
assert_int(d._visible.size()).is_equal(0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_empty_text_does_not_advance_stagger_timer() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("", 5.0)
assert_float(d._next_fade_in_msec).is_equal(0.0)
d.queue_free()
func test_empty_text_when_slots_full_does_not_enqueue() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("", 5.0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
# ---------------------------------------------------------------------------
# Single-line display
# ---------------------------------------------------------------------------
func test_single_line_goes_to_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("One.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_show_monologue_sets_stagger_timer() -> void:
var d = _make_display()
if d == null: return
var before := float(Time.get_ticks_msec())
d.show_monologue("Stagger.", 5.0)
assert_float(d._next_fade_in_msec).is_greater(before)
d.queue_free()
# ---------------------------------------------------------------------------
# MAX_VISIBLE = 3 simultaneous lines
# ---------------------------------------------------------------------------
func test_three_lines_all_visible() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_fourth_line_queues_when_slots_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("D", 5.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(1)
d.queue_free()
# ---------------------------------------------------------------------------
# No-overwrite contract (P0, #477)
# ---------------------------------------------------------------------------
func test_new_line_does_not_replace_first_visible_line() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First line.", 10.0)
var first_text := _label_text(d)
# Force stagger active; second call must queue, not display
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
d.show_monologue("Second line.", 5.0)
assert_that(_label_text(d)).is_equal(first_text)
d.queue_free()
func test_two_visible_lines_coexist_without_overwriting() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
assert_int(d._visible.size()).is_equal(2)
d.queue_free()
# ---------------------------------------------------------------------------
# Priority queue
# ---------------------------------------------------------------------------
func test_queue_sorted_highest_priority_first() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("low", 5.0, 1)
d.show_monologue("high", 5.0, 4)
d.show_monologue("normal", 5.0, 2)
assert_int(d._queue[0].priority).is_equal(4)
d.queue_free()
func test_queue_drop_replaces_lowest_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("low_%d" % i, 5.0, 1)
d.show_monologue("critical!", 5.0, 9)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
var has_critical := false
for e in d._queue:
if e.priority == 9:
has_critical = true
assert_that(has_critical).is_true()
d.queue_free()
func test_queue_ignores_lower_priority_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("hi_%d" % i, 5.0, 5)
d.show_monologue("noise", 1.0, 1)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
for e in d._queue:
assert_int(e.priority).is_equal(5)
d.queue_free()
func test_queue_never_exceeds_max_depth() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE + 20):
d.show_monologue("flood_%d" % i, 1.0, 2)
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
d.queue_free()
# ---------------------------------------------------------------------------
# Expire and drain
# ---------------------------------------------------------------------------
func test_expired_slot_removed_from_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Expires.", 1.0)
d._visible[0].expire_timer = -0.1
d._process(0.0)
assert_int(d._visible.size()).is_equal(0)
d.queue_free()
func test_queue_drains_when_slot_opens() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("Queued.", 5.0)
d._visible[0].expire_timer = -0.1
d._next_fade_in_msec = 0.0 # stagger elapsed
d._process(0.0)
assert_int(d._queue.size()).is_equal(0)
assert_int(d._visible.size()).is_equal(3)
d.queue_free()
# ---------------------------------------------------------------------------
# Stagger
# ---------------------------------------------------------------------------
func test_second_call_within_stagger_period_queues() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First.", 5.0)
# _next_fade_in_msec is now ~150ms in the future
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(1)
d.queue_free()
func test_stagger_elapsed_allows_second_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First.", 5.0)
d._next_fade_in_msec = 0.0 # manually expire stagger
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(2)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
# ---------------------------------------------------------------------------
# BBCode output
# ---------------------------------------------------------------------------
func test_text_wrapped_in_italic_bbcode() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Italic line.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[i]")
assert_that(txt).contains("[/i]")
assert_that(txt).contains("Italic line.")
d.queue_free()
func test_text_has_color_bbcode() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Coloured.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#")
assert_that(txt).contains("[/color]")
d.queue_free()
# ---------------------------------------------------------------------------
# Lattice colour palette
# ---------------------------------------------------------------------------
func test_augmented_colour_differs_from_baseline() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_augmented"
d.show_monologue("Detective.", 5.0)
var aug_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Smuggler.", 5.0)
var base_txt := _label_text(d)
assert_that(aug_txt).is_not_equal(base_txt)
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
func test_urgent_colour_differs_from_standard() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Normal.", 5.0, 2, false)
var std_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
d.show_monologue("Urgent!", 5.0, 3, true)
var urg_txt := _label_text(d)
assert_that(std_txt).is_not_equal(urg_txt)
d.queue_free()
func test_unknown_profile_falls_back_without_crash() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_hypothetical_tier_x"
d.show_monologue("Future proof.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
# ---------------------------------------------------------------------------
# Slot lifecycle
# ---------------------------------------------------------------------------
func test_visible_slot_has_tween() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Has tween.", 5.0)
assert_that(d._visible[0].tween).is_not_null()
d.queue_free()
func test_visible_slot_stores_priority() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Priority 7.", 5.0, 7)
assert_int(d._visible[0].priority).is_equal(7)
d.queue_free()
func test_expire_timer_decrements_in_process() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Timer.", 10.0)
d._process(1.5)
assert_float(d._visible[0].expire_timer).is_less(10.0)
d.queue_free()
# ---------------------------------------------------------------------------
# GameState integration — current_monologue field (v5, #414)
# These tests do not instantiate the scene — they verify apply_snapshot()
# correctly populates and clears current_monologue so the display layer
# receives valid data (or null) every tick.
#
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
# ---------------------------------------------------------------------------
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
## D-016: Snapshot without current_monologue must clear the field.
## Prevents a stale line from a previous tick persisting as ghost text.
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.current_monologue).is_null()
func test_gamestate_monologue_set_from_snapshot() -> void:
## apply_snapshot populates current_monologue when the field is a dict.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
## Defensive: a non-dictionary value from the server must be rejected (null).
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
assert_that(GameState.current_monologue).is_null()
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
})
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
assert_that(dur).is_equal_approx(7.5, 0.001)
func test_gamestate_monologue_id_survives_round_trip() -> void:
## The id field is used by SimBridge carry-forward deduplication (#477).
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
})
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
## Each snapshot with a monologue replaces the previous value — no accumulation.
GameState.apply_snapshot({
"tick": 1,
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
})
GameState.apply_snapshot({
"tick": 2,
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
})
assert_that(GameState.current_monologue.get("id")).is_equal("second")
# ---------------------------------------------------------------------------
# D-049 Z-layer / canvas scope compliance
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
# Two tests: (1) constant sanity, (2) scene tree structural verification.
# ---------------------------------------------------------------------------
func test_canvas_ui_constant_is_20() -> void:
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
## Sanity check — constant must not drift from the spec.
assert_that(Constants.CANVAS_UI).is_equal(20)
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
## D-049: Structural verification — MonologueDisplay must be a direct child of
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
## Catches regressions where the node gets accidentally moved to InsertOverlay
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
##
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
assert_that(mono != null).is_true()
var parent: Node = mono.get_parent()
assert_that(parent is CanvasLayer).is_true()
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
@@ -0,0 +1 @@
uid://bn4a46egt2uol
+199
View File
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
# Regression test for #345: protocol uses entity_id (not id).
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
func test_entity_renderer_rejects_old_id_field_format() -> void:
var renderer := _make_entity_renderer()
# Old (wrong) format: "id" instead of "entity_id"
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(0)
# Correct format: "entity_id" — should be accepted
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_entity_renderer_player_color_differs_from_npc() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities)
@@ -334,6 +348,191 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- EntityRenderer: regression #345 (entity_id field name bug) --
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
# entity_data.has("entity_id"), causing all entities to be silently dropped.
# Fix: use entity_id (Protocol v2 canonical field name).
func test_regression_345_entity_id_field_renders() -> void:
# Regression: entity with correct "entity_id" field MUST be rendered.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
renderer.queue_free()
func test_regression_345_old_id_field_not_rendered() -> void:
# Negative regression: entity using OLD field name "id" (not "entity_id")
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(0)
renderer.queue_free()
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
# Only the entity_id entity should appear — no cross-contamination.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.size()).is_equal(1)
assert_that(renderer.entity_nodes.has(1)).is_true()
assert_that(renderer.entity_nodes.has(2)).is_false()
renderer.queue_free()
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
# not by a string "entity_id" or by the old "id" value.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
assert_that(renderer.entity_nodes.has(42)).is_true()
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
assert_that(renderer.entity_nodes.has(0)).is_false()
renderer.queue_free()
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
# Regression: entity rendered via entity_id must have correct pixel position.
var renderer := _make_entity_renderer()
renderer.update_entities([
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
])
var node = renderer.entity_nodes[5]
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
renderer.queue_free()
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
func _make_sound_indicator_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(SoundIndicatorScript)
add_child(renderer)
return renderer
func test_sound_indicator_accepts_medium_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(renderer._indicators.size()).is_equal(2)
renderer.queue_free()
func test_sound_indicator_drops_events_without_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"event_type": "Footstep"}, # missing x, y
{"x": 5.0, "event_type": "Voice"}, # missing y
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_appends_new_events() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
])
assert_that(renderer._indicators.size()).is_equal(2)
# New update appends — existing indicators persist until they expire
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
assert_that(renderer._indicators.size()).is_equal(3)
renderer.queue_free()
func test_sound_indicator_empty_update_preserves_existing() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Empty update does not clear existing indicators — they expire via _process
renderer.update_sound_events([])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_color_voice() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
assert_that(renderer.color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
renderer.queue_free()
func test_sound_indicator_color_danger() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
assert_that(renderer.color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
renderer.queue_free()
func test_sound_indicator_color_neutral_for_unknown() -> void:
var renderer := _make_sound_indicator_renderer()
assert_that(renderer.color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
assert_that(renderer.color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
renderer.queue_free()
func test_sound_indicator_deduplicates_same_position() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
# Same position again — should reset timer, not add a duplicate
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators.size()).is_equal(1)
renderer.queue_free()
func test_sound_indicator_elapsed_starts_at_zero() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
renderer.queue_free()
func test_sound_indicator_events_expire_after_lifetime() -> void:
var renderer := _make_sound_indicator_renderer()
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
# Manually age the indicator past its lifetime
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
renderer._process(0.0) # zero delta so no additional aging
assert_that(renderer._indicators.size()).is_equal(0)
renderer.queue_free()
# -- GameState: medium_sound_events from snapshot --
func test_game_state_filters_medium_sound_events() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
GameState.apply_snapshot({"tick": 1})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
GameState.apply_snapshot({
"tick": 1,
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
# Next tick without sound_events clears them
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.medium_sound_events.size()).is_equal(0)
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+212
View File
@@ -0,0 +1,212 @@
## Sprint 15 — Smooth camera movement tests (#117)
## Validates exponential lerp, teleport snap, and configurable smoothing.
## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking).
class_name TestSmoothCameraSprint15
extends GdUnitTestSuite
var _instance: Node = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# --- Configurable smoothing constant ---
func test_camera_smoothing_speed_constant_defined() -> void:
# #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable).
assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true()
func test_camera_smoothing_speed_constant_reasonable() -> void:
# #117: Speed should produce smooth-but-responsive feel (2.020.0 range).
assert_that(
Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0
).is_true()
# --- Manual lerp, no Godot built-in smoothing ---
func test_godot_smoothing_disabled_at_ready() -> void:
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_false()
func test_godot_smoothing_stays_off_after_frames() -> void:
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
for i in range(5):
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_false()
# --- Interpolated tracking (no snap) ---
func test_camera_lerps_not_snaps_on_player_move() -> void:
# #117: When player moves, camera should lerp (not snap) to new position.
# After 1 frame at ~60fps, camera should be partway there — not at target.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var start_y := camera.global_position.y # anchored at player (10,10) → 320px
SimBridge._test_input_queue.append("MoveNorth")
_instance._process(0.016)
# Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320.
var target_y: float = GameState.player_position.y * Constants.TILE_SIZE
assert_that(camera.global_position.y < start_y).is_true()
assert_that(camera.global_position.y > target_y).is_true()
func test_camera_converges_to_player_over_multiple_frames() -> void:
# #117: After enough frames the camera should be within 1px of target.
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
SimBridge._test_input_queue.append("MoveNorth")
_instance._process(0.016) # trigger the move, get new player position
var target := GameState.player_position * Constants.TILE_SIZE
# Run 120 frames (~2s at 60fps) — converges within 1px for any speed ≥ 2.0
for i in range(120):
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
var dist := camera.global_position.distance_to(target)
assert_that(dist < 1.0).is_true()
func test_camera_stationary_player_no_drift() -> void:
# #117: When player is stationary, camera should not drift (lerp to same point).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var initial_pos := camera.global_position
# Run several frames with no movement
for i in range(10):
_instance._process(0.016)
# Camera should still be at anchored position (target = same point).
# Use distance check — lerp toward same point may introduce float rounding.
assert_that(camera.global_position.distance_to(initial_pos) < 0.01).is_true()
# --- Teleport snap ---
func test_teleport_snaps_camera_immediately() -> void:
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
# Manually displace camera, set the flag, call _process — camera should snap to target.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
# Displace camera from its anchored position
camera.global_position = Vector2(0, 0)
# Set teleport flag — next _process() should snap to player target
_instance._camera_anchored = true
_instance._teleport_in_progress = true
_instance._process(0.016)
# Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320)
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).is_equal(expected)
func test_teleport_flag_cleared_after_snap() -> void:
# #117: _teleport_in_progress must be false after the snap frame.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._camera_anchored = true
_instance._teleport_in_progress = true
_instance._process(0.016)
assert_that(_instance._teleport_in_progress).is_false()
func test_camera_resumes_lerp_after_teleport() -> void:
# #117: Frame after teleport snap must resume lerp (not continue snapping).
# After teleport flag clears, any position delta produces lerp movement.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Frame 1: teleport snap — camera displaced, flag set, expect snap
var camera: Camera2D = _instance.get_node("Camera2D")
camera.global_position = Vector2(0, 0)
_instance._camera_anchored = true
_instance._teleport_in_progress = true
_instance._process(0.016)
# After snap: camera at player position (10,10) = (320, 320)
var post_snap_y := camera.global_position.y
# Frame 2: player moves north — camera should lerp, not snap
SimBridge._test_input_queue.append("MoveNorth")
_instance._process(0.016)
var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE
# Camera must be between snap position and new target (lerping, not snapping)
assert_that(camera.global_position.y < post_snap_y).is_true()
assert_that(camera.global_position.y > new_target_y).is_true()
# Teleport flag must not be re-set by normal movement
assert_that(_instance._teleport_in_progress).is_false()
# --- D-015: Fixed-north camera ---
func test_camera_no_rotation() -> void:
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.rotation).is_equal(0.0)
_instance._process(0.016)
assert_that(camera.rotation).is_equal(0.0)
@@ -0,0 +1 @@
uid://s15smoothcam1
+236
View File
@@ -0,0 +1,236 @@
## Test suite for #126: Medium-range visual indicators (D-018)
## Spec refs:
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
## D-049: Z-stack — indicators must render above fog layer (z:900)
## D-069: Color palette for indicators (neutral/voice/danger)
##
## Color spec (sprint brief + D-018/D-069):
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
##
## Test layers:
## 1. D-018 Indicator color spec — verifies Constants match the spec
## 2. D-018 Range routing — Medium vs Close events belong to different systems
## 3. Direction geometry — angle from player position to sound source
## 4. Snapshot integration stubs — graceful skip until #126 lands
## 5. Indicator script API — graceful skip until Stig creates the script
class_name TestSoundIndicators
extends GdUnitTestSuite
# ==============================================================================
# Layer 1: D-018/D-069 Indicator Color Spec
# Sound indicators reuse existing Constants to avoid palette drift.
# ==============================================================================
func test_d018_indicator_color_neutral_is_insert_color() -> void:
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
## Indicators use the insert visual language (diegetic, not world-layer UI).
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
func test_d018_indicator_color_voices_is_poi_amber() -> void:
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d018_indicator_color_danger_is_hostile_red() -> void:
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d018_three_indicator_colors_are_distinct() -> void:
## D-018: All three indicator states must be visually distinct.
var neutral: Color = Constants.INSERT_COLOR_TEXT
var voice: Color = Constants.ENTITY_COLOR_POI
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
assert_that(neutral != voice).is_true()
assert_that(neutral != danger).is_true()
assert_that(voice != danger).is_true()
func test_d049_fog_layer_z_index() -> void:
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
## This verifies the z constant — indicator placement depends on it.
assert_that(Constants.Z_FOG).is_equal(900)
# ==============================================================================
# Layer 2: D-018 Range Routing — Medium vs Close
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
# Medium-range events → fog-edge indicators (no direct audio)
# ==============================================================================
func test_d018_medium_range_events_stored_in_snapshot() -> void:
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
## D-018: Close events must NOT appear in medium_sound_events.
## They go to close_sound_events for 2D positional audio.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
assert_that(GameState.medium_sound_events.size()).is_equal(1)
assert_that(GameState.close_sound_events.size()).is_equal(1)
func test_d018_medium_range_event_has_position_fields() -> void:
## Medium-range events must include source position for direction calculation.
GameState.apply_snapshot({
"tick": 1,
"sound_events": [
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
]
})
var ev: Dictionary = GameState.medium_sound_events[0]
assert_that(ev.has("x")).is_true()
assert_that(ev.has("y")).is_true()
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
# ==============================================================================
# Layer 3: Direction Geometry
# The fog-edge indicator must point toward the sound source from the player.
# These tests validate the math used by the indicator rendering — if the
# indicator uses Vector2.angle() or atan2, these confirm expected output.
# ==============================================================================
## Compute directional angle (radians) from player tile pos to source tile pos.
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
func _direction_angle(player: Vector2, source: Vector2) -> float:
return player.direction_to(source).angle()
func test_indicator_direction_north() -> void:
## Source directly north of player (lower y in Godot) → angle -PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
func test_indicator_direction_east() -> void:
## Source directly east → angle 0.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
assert_that(angle).is_equal_approx(0.0, 0.01)
func test_indicator_direction_south() -> void:
## Source directly south (higher y) → angle PI/2.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
func test_indicator_direction_west() -> void:
## Source directly west → angle ≈ ±PI.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
func test_indicator_direction_northeast() -> void:
## Source equal offset northeast → angle -PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
func test_indicator_direction_southeast() -> void:
## Source equal offset southeast → angle PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
func test_indicator_direction_southwest() -> void:
## Source equal offset southwest → angle 3PI/4.
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
func test_indicator_direction_changes_with_source_position() -> void:
## Sanity: direction is not degenerate — different source positions give different angles.
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
assert_that(north != east).is_true()
assert_that(east != south).is_true()
assert_that(south != north).is_true()
func test_indicator_direction_magnitude_independent_of_distance() -> void:
## Direction angle must not vary with distance — only with angle from player.
## Source 5 tiles north vs 20 tiles north: same angle.
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
assert_that(near).is_equal_approx(far, 0.01)
# ==============================================================================
# Layer 4: Indicator Script API (graceful stub)
# Tests activate when Stig creates the indicator rendering script.
# Expected paths checked in order — first match wins.
# ==============================================================================
func _load_indicator_script() -> GDScript:
## Load the sound indicator renderer script (#126 implemented).
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
if ResourceLoader.exists(path):
return load(path) as GDScript
return null
func test_indicator_script_exists() -> void:
## #126: sound_indicator_renderer.gd must exist at the canonical path.
var script: GDScript = _load_indicator_script()
assert_that(script != null).is_true()
func test_indicator_has_update_sound_events_method() -> void:
## #126: Indicator must expose update_sound_events(events: Array).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var method_names: Array = script.get_script_method_list().map(
func(m: Dictionary) -> String: return m.name
)
assert_that(method_names.has("update_sound_events")).is_true()
func test_indicator_does_not_crash_for_empty_events() -> void:
## Edge case: empty sound_events list must not crash update_sound_events.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([])
# Empty update on fresh renderer — no indicators exist, no crash
assert_that(indicator._indicators.size()).is_equal(0)
indicator.queue_free()
func test_indicator_existing_survive_empty_update() -> void:
## Existing indicators persist through an empty update (expire via _process only).
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.update_sound_events([])
assert_that(indicator._indicators.size()).is_equal(1)
indicator.queue_free()
func test_indicator_stores_valid_medium_events() -> void:
## #126: update_sound_events must store events with x and y fields.
var script: GDScript = _load_indicator_script()
if script == null:
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
return
var indicator := Node2D.new()
indicator.set_script(script)
add_child(indicator)
indicator.update_sound_events([
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
])
assert_that(indicator._indicators.size()).is_equal(2)
indicator.queue_free()
@@ -0,0 +1 @@
uid://tp8wpp6t8tvx
+312
View File
@@ -0,0 +1,312 @@
## Sprint 15 — Basic UI framework validation tests (#74)
## Validates HUD structure, z-layer hierarchy, insert_active control,
## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07.
class_name TestUIFrameworkSprint15
extends GdUnitTestSuite
var _instance: Node = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.insert_active = true
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -------------------------------------------------------------------------
# D-076: Layout constants
# -------------------------------------------------------------------------
func test_dialogue_max_width_set() -> void:
# DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review).
assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200)
# -------------------------------------------------------------------------
# D-049: Z-layer scene hierarchy
# -------------------------------------------------------------------------
func test_insert_overlay_is_canvas_layer_10() -> void:
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
# Constants.CANVAS_INSERT must match.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay")
assert_that(insert_overlay).is_not_null()
assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT)
func test_ui_layer_is_canvas_layer_20() -> void:
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var ui_layer: CanvasLayer = _instance.get_node("UILayer")
assert_that(ui_layer).is_not_null()
assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI)
func test_modal_layer_is_canvas_layer_30() -> void:
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var modal_layer: CanvasLayer = _instance.get_node("ModalLayer")
assert_that(modal_layer).is_not_null()
assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL)
func test_ui_layer_above_insert_overlay() -> void:
# D-049: UILayer (20) must render above InsertOverlay (10).
assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT)
func test_modal_layer_above_ui_layer() -> void:
# D-049: ModalLayer (30) must render above UILayer (20).
assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI)
# -------------------------------------------------------------------------
# D-049: Required nodes exist in correct layers
# -------------------------------------------------------------------------
func test_monologue_display_exists_in_ui_layer() -> void:
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null()
func test_stance_indicator_exists_in_ui_layer() -> void:
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null()
func test_minimap_placeholder_exists_in_ui_layer() -> void:
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
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()
func test_hud_exists_in_ui_layer() -> void:
# D-049: HUD must be in UILayer.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null()
func test_interaction_list_exists_in_insert_overlay() -> void:
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null()
func test_dialogue_box_exists_in_insert_overlay() -> void:
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null()
func test_world_radial_exists_in_insert_overlay() -> void:
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null()
func test_cursor_renderer_exists_in_ui_layer() -> void:
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null()
# -------------------------------------------------------------------------
# OQ-07 / D-056: insert_active controls z-layer 6 visibility
# -------------------------------------------------------------------------
func test_gamestate_insert_active_defaults_true() -> void:
# OQ-07: v0.1 characters all have inserts — default true.
assert_that(GameState.insert_active).is_true()
func test_insert_active_propagates_on_process() -> void:
# OQ-07 (#522): After apply_snapshot with insert_active=false,
# the next _process() call must propagate the state to z-layer-6 nodes.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Inject a snapshot with insert_active = false
var snap := SimBridge._test_snapshot()
snap["insert_active"] = false
GameState.apply_snapshot(snap)
assert_that(GameState.insert_active).is_false()
func test_insert_active_true_from_snapshot() -> void:
# OQ-07: Snapshot with insert_active=true keeps GameState in default-on state.
var snap := SimBridge._test_snapshot()
snap["insert_active"] = true
GameState.apply_snapshot(snap)
assert_that(GameState.insert_active).is_true()
func test_insert_active_missing_field_defaults_true() -> void:
# OQ-07: Old servers without insert_active field must not disable the insert.
var snap := SimBridge._test_snapshot()
snap.erase("insert_active")
GameState.apply_snapshot(snap)
assert_that(GameState.insert_active).is_true()
# -------------------------------------------------------------------------
# #241 stub: follow_target_id for entity sprite system
# -------------------------------------------------------------------------
func test_follow_target_id_stub_exists() -> void:
# #72 / #241: follow_target_id stub must exist on GameState with default -1.
# Populated by server ticket #241 (Follow verb) when it lands.
assert_that(GameState.follow_target_id).is_equal(-1)
func test_follow_target_id_is_negative_one_by_default() -> void:
# #72: -1 means "not following" — client #72 checks this for entity highlight.
SimBridge.reset_test_state()
GameState.apply_snapshot(SimBridge._test_snapshot())
# Server doesn't send follow_target_id yet — must stay -1 after snapshot
assert_that(GameState.follow_target_id).is_equal(-1)
# -------------------------------------------------------------------------
# Monologue display wiring (#414)
# -------------------------------------------------------------------------
func test_monologue_display_receives_first_tick_monologue() -> void:
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Tick 1 snapshot has a monologue (SimBridge test mode)
# _ready() consumes tick 0 (no monologue). _process() here gets tick 1.
_instance._process(0.016)
# If monologue_display received it, current_monologue is cleared (consume-once)
assert_that(GameState.current_monologue).is_null()
# -------------------------------------------------------------------------
# Regression: Sprint 14 integration proofs (D-030 regression markers)
# -------------------------------------------------------------------------
func test_fog_group_exists_in_world() -> void:
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null()
func test_floor_tiles_in_fog_group() -> void:
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null()
func test_entities_in_ysort_group() -> void:
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null()
# -------------------------------------------------------------------------
# #71: Tilemap z-filter — FloorTiles only renders floor level 0
# -------------------------------------------------------------------------
func test_tile_renderer_skips_nonzero_z() -> void:
# #71: Tiles with z != 0 must be filtered out by update_tiles().
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles")
assert_that(tile_renderer).is_not_null()
# Feed tiles at z=0 and z=1
var tiles: Array = [
{"x": 0, "y": 0, "z": 0, "type": "floor"},
{"x": 1, "y": 0, "z": 1, "type": "floor"},
{"x": 2, "y": 0, "z": 0, "type": "wall"},
{"x": 3, "y": 0, "z": 2, "type": "door"},
]
tile_renderer.update_tiles(tiles)
# z=0 tiles should be present
assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1)
assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1)
# z=1 and z=2 tiles should NOT be present (-1 = no cell)
assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1)
assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1)
@@ -0,0 +1 @@
uid://s15uiframe001
+2 -1
View File
@@ -243,6 +243,7 @@ func _close() -> void:
_active = false
visible = false
if _line_edit:
_line_edit.release_focus()
_line_edit.queue_free()
_line_edit = null
@@ -344,7 +345,7 @@ func _save_report(description: String) -> void:
else:
push_error("BugReport: failed to write %s" % seed_path)
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
+1
View File
@@ -0,0 +1 @@
uid://c5xgtnkp0butl
+486 -102
View File
@@ -1,37 +1,72 @@
extends Control
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
# #535: Unified conversation log — player dialogue and overheard NPC-NPC conversations
# flow chronologically. Oldest at top, scrolls up. Options at bottom.
#
# InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled.
# NPC speech top, player response options below, left-aligned.
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
# Auto-pause in single-player when dialogue is open (D-061).
# D-063: Confrontation options render italic, trigger monologue beat before send.
# D-064: Walk-away via WASD dismisses active conversation (log entries persist).
# dialogue_active held until fade completes (D-064 auto-pause spec).
# D-078: Overheard NPC-NPC lines prefixed with ┃ glyph + desaturated colours.
signal option_selected(response_id: String, text: String)
signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
signal unpause_requested # D-061: auto-unpause
@onready var panel: PanelContainer = $PanelContainer
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
var _is_showing: bool = false
# -- Log state --
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
# Entry format (entity-anchored): {speaker_id: int, target_id: int, text, is_passive, pinned, timestamp_msec}
# pinned entries do not expire (active conversation lines, Araminta review).
var _log_entries: Array[Dictionary] = []
var _in_player_conversation: bool = false
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
# Entity ID → {name: String, color_index: int}
# Populated from server events; drives retroactive re-render when NPC names resolve.
var _entity_display: Dictionary = {}
# -- Option state --
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = []
var _option_texts: Array[String] = []
var _option_is_confrontation: Array[bool] = []
var _npc_name: String = ""
# -- UI state --
var _active_tween: Tween = null
var _beat_tween: Tween = null # D-063: confrontation beat delay
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = [] # response_id per option, same index
var _option_texts: Array[String] = [] # raw display text per option
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
var _npc_name: String = ""
# -- Theme (loaded from data/dialogue-theme.yaml) --
var _player_color: Color = Color("#e0e8ff")
var _npc_colors: Array[Color] = []
var _arrow_color: Color = Color("#8890a0")
var _speech_color: Color = Color("#c8d0e0")
var _passive_opacity: float = 0.9
var _entry_lifetime: float = 45.0
var _entry_fade: float = 5.0
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
const FADE_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280)
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
const PLAYER_NAME: String = "You"
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
# D-064: movement actions that trigger walk-away
const _WALK_AWAY_ACTIONS: Array[StringName] = [
@@ -41,59 +76,275 @@ const _WALK_AWAY_ACTIONS: Array[StringName] = [
func _ready() -> void:
panel.modulate.a = 0.0
visible = false
_is_showing = false
# Panel is always visible as a permanent insert UI element (D-061).
# Content fades in/out but the panel frame stays on screen.
visible = true
panel.modulate.a = 1.0
mouse_filter = Control.MOUSE_FILTER_IGNORE
_load_theme()
_update_layout()
get_viewport().size_changed.connect(_update_layout)
func _process(_delta: float) -> void:
_expire_entries()
if _log_dirty:
_log_dirty = false
_rebuild_log()
func _unhandled_input(event: InputEvent) -> void:
if not _is_showing:
if not _in_player_conversation:
return
# D-064: WASD during dialogue → walk-away, 300ms fade
# Number keys 1-3 select dialogue options
if event is InputEventKey and event.pressed:
var key_index := -1
if event.keycode == KEY_1: key_index = 0
elif event.keycode == KEY_2: key_index = 1
elif event.keycode == KEY_3: key_index = 2
if key_index >= 0 and key_index < _option_controls.size():
get_viewport().set_input_as_handled()
_on_option_pressed(key_index)
return
# D-064: WASD during active player dialogue → walk-away
if event is InputEventKey and event.pressed:
for action in _WALK_AWAY_ACTIONS:
if event.is_action_pressed(action):
get_viewport().set_input_as_handled()
_cancel_beat()
hide_dialogue()
_end_player_conversation()
dialogue_dismissed.emit()
return
# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport.
# -- Theme loading --
func _load_theme() -> void:
# Default NPC palette in case file not found
_npc_colors = [
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
]
if not FileAccess.file_exists(THEME_PATH):
push_warning("DialogueBox: theme file not found: %s — using defaults" % THEME_PATH)
return
var file := FileAccess.open(THEME_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
var strings := UIStrings._parse_yaml(text)
if strings.has("player_color"):
_player_color = Color(strings["player_color"])
if strings.has("arrow_color"):
_arrow_color = Color(strings["arrow_color"])
if strings.has("speech_color"):
_speech_color = Color(strings["speech_color"])
if strings.has("passive_opacity"):
_passive_opacity = float(strings["passive_opacity"])
if strings.has("entry_lifetime_seconds"):
_entry_lifetime = float(strings["entry_lifetime_seconds"])
if strings.has("entry_fade_seconds"):
_entry_fade = float(strings["entry_fade_seconds"])
# Load NPC colors from indexed keys (npc_colors.0 through npc_colors.7)
var loaded_colors: Array[Color] = []
for i in range(16): # support up to 16 palette entries
var key := "npc_colors.%d" % i
if strings.has(key):
loaded_colors.append(Color(strings[key]))
if loaded_colors.size() > 0:
_npc_colors = loaded_colors
# -- Responsive layout --
func _update_layout() -> void:
var vp := get_viewport_rect().size
var max_h := vp.y * MAX_HEIGHT_RATIO
var w := minf(MAX_WIDTH_PX, vp.x * 0.65)
var w := minf(MAX_WIDTH_PX, vp.x * 0.85)
panel.offset_left = -w / 2.0
panel.offset_right = w / 2.0
panel.offset_top = -max_h
# Show dialogue with NPC speech and response options.
# npc_name: who is speaking (displayed as prefix)
# speech: the NPC's dialogue text
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
# D-062: locked options are invisible (server filters before sending).
# D-063: confrontation options render italic.
# -- Log entry management --
## Append a dialogue line to the log.
## speaker/target: display names. text: the spoken line.
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
var pinned := not is_passive and _in_player_conversation
_log_entries.append({
"speaker": speaker,
"target": target,
"text": text,
"is_passive": is_passive,
"pinned": pinned,
"timestamp_msec": Time.get_ticks_msec(),
})
_log_dirty = true
_ensure_visible()
## Append an overheard conversation event (D-078).
## Stores entity IDs for retroactive name resolution when NPC display names change.
func append_conversation_event(event: Dictionary) -> void:
var text: String = event.get("occluded_line", "")
if text.is_empty():
return
var speaker_id: int = event.get("speaker_id", -1)
var target_id: int = event.get("target_id", -1)
var speaker_name: String = event.get("speaker_name", "?")
var target_name: String = event.get("target_name", "?")
var speaker_color_index: int = event.get("speaker_color_index", -1)
var target_color_index: int = event.get("target_color_index", -1)
# Update entity display registry — set dirty if a known name changed (retroactive update)
if speaker_id >= 0:
var prev: Dictionary = _entity_display.get(speaker_id, {})
_entity_display[speaker_id] = {"name": speaker_name, "color_index": speaker_color_index}
if prev.has("name") and prev.get("name", "") != speaker_name:
_log_dirty = true
if target_id >= 0:
var prev: Dictionary = _entity_display.get(target_id, {})
_entity_display[target_id] = {"name": target_name, "color_index": target_color_index}
if prev.has("name") and prev.get("name", "") != target_name:
_log_dirty = true
var entry: Dictionary = {
"text": text,
"is_passive": true,
"pinned": false,
"timestamp_msec": Time.get_ticks_msec(),
}
if speaker_id >= 0:
entry["speaker_id"] = speaker_id
entry["target_id"] = target_id
else:
# Fallback: no entity IDs on wire, store raw names for legacy rendering
entry["speaker"] = speaker_name
entry["target"] = target_name
_log_entries.append(entry)
_log_dirty = true
_ensure_visible()
## Update entity display name and color index — called when Talk responses arrive.
## Triggers retroactive log re-render if the entity's displayed name has changed.
func update_entity_display(entity_id: int, name: String, color_index: int) -> void:
if entity_id < 0:
return
var prev: Dictionary = _entity_display.get(entity_id, {})
_entity_display[entity_id] = {"name": name, "color_index": color_index}
if prev.has("name") and prev.get("name", "") != name:
_log_dirty = true
## Handle conversation_ended — no-op currently (entries expire via timeout).
func on_conversation_ended(_event: Dictionary) -> void:
pass
## Append the player's chosen response to the log.
func append_player_line(target_npc: String, text: String) -> void:
append_line(PLAYER_NAME, target_npc, text, false)
## Append an NPC follow-up line (from dialogue_response).
func append_dialogue_response(npc_name: String, text: String) -> void:
append_line(npc_name, PLAYER_NAME, text, false)
# -- Active player conversation --
## Show dialogue with NPC speech and response options.
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
_npc_name = npc_name
_cancel_beat()
_in_player_conversation = true
# NPC speech — name prefix in bold
if npc_name.is_empty():
npc_speech.text = speech
else:
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
# Append NPC's line to the log
if not speech.is_empty():
append_line(npc_name, PLAYER_NAME, speech, false)
# Clear old options
# Clear old options and show new ones
_clear_options()
_show_options(options)
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-061: auto-pause — signal to main.gd for input recording (#507)
pause_requested.emit()
## End active player conversation — clears options but preserves log.
## D-064: dialogue_active cleared immediately so WASD resumes.
## Log entries remain visible and expire via timeout (cosmetic only).
func _end_player_conversation() -> void:
_in_player_conversation = false
_clear_options()
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Unpin all entries and reset their timestamps so timeout starts now (Araminta review)
var now := Time.get_ticks_msec()
for entry in _log_entries:
if entry.pinned:
entry.pinned = false
entry.timestamp_msec = now
_log_dirty = true
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: unblock movement immediately — log entries stay visible but don't block input.
GameState.dialogue_active = false
# If no entries remain, hide the panel with fade.
if _log_entries.is_empty():
hide_dialogue()
## End active dialogue state. Panel stays visible (permanent insert UI element).
func hide_dialogue() -> void:
if _in_player_conversation:
_end_player_conversation()
return # _end_player_conversation may call hide_dialogue if log is empty
GameState.dialogue_active = false
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
func has_active_entries() -> bool:
return _log_entries.size() > 0 or _in_player_conversation
# -- Options --
func _show_options(options: Array) -> void:
var sorted_opts: Array = options.duplicate()
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
var count := mini(sorted_opts.size(), MAX_OPTIONS)
@@ -103,27 +354,21 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
# RichTextLabel for BBCode support (D-063: confrontation italic)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
label.scroll_active = 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
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
if is_confrontation:
label.text = "[i]%s[/i]" % raw_text
else:
label.text = raw_text
var numbered_text := "%d. %s" % [i + 1, raw_text]
label.text = numbered_text
# Click handling
var idx := i
label.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx)
)
# Hover color
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
@@ -133,46 +378,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
# Show with fade
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
_is_showing = true
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
# D-061: auto-pause in single-player when dialogue opens
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
# Hide dialogue with fade (D-064: 300ms)
func hide_dialogue() -> void:
if not _is_showing:
return
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
# D-061: unpause when dialogue closes
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
_clear_options()
GameState.dialogue_active = false # D-064: unblock movement after fade completes
)
func is_dialogue_active() -> bool:
return _is_showing or GameState.dialogue_active
func _on_option_pressed(index: int) -> void:
if index >= _option_controls.size():
@@ -181,46 +386,40 @@ func _on_option_pressed(index: int) -> void:
var text: String = _option_texts[index] if index < _option_texts.size() else ""
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
# Append player's chosen response to the log
append_player_line(_npc_name, text)
if is_confront:
_start_confrontation_beat(rid, text)
else:
option_selected.emit(rid, text)
hide_dialogue()
_end_player_conversation()
# D-063: Confrontation beat — delay before sending response.
# 1. Dim dialogue to 70%, show monologue, dip audio
# 2. Wait CONFRONTATION_BEAT_DURATION
# 3. Emit option_selected, restore audio, hide dialogue
# -- Confrontation beat (D-063) --
func _start_confrontation_beat(response_id: String, text: String) -> void:
# Disable option clicks during beat
for ctrl in _option_controls:
if is_instance_valid(ctrl):
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Dim dialogue box
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
# D-063: monologue beat — text from ui-strings.yaml (D-042)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
# D-063: audio dip via AudioManager
AudioManager.apply_dip("confrontation")
# Delay, then complete
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
option_selected.emit(response_id, text)
hide_dialogue()
_end_player_conversation()
)
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
@@ -228,6 +427,191 @@ func _cancel_beat() -> void:
AudioManager.clear_dip()
# -- Log rendering --
## Rebuild the dialogue log BBCode from all non-expired entries.
func _rebuild_log() -> void:
if not dialogue_log:
return
var now := Time.get_ticks_msec()
var bbcode := ""
for entry in _log_entries:
var alpha: float = 1.0
if not entry.pinned:
var age_sec: float = (now - entry.timestamp_msec) / 1000.0
if age_sec > _entry_lifetime:
alpha = clampf(1.0 - (age_sec - _entry_lifetime) / _entry_fade, 0.0, 1.0)
if entry.is_passive:
alpha *= _passive_opacity
if alpha <= 0.0:
continue
var line := _format_entry(entry, alpha)
if not bbcode.is_empty():
bbcode += "\n"
bbcode += line
dialogue_log.text = bbcode
## Format a single log entry as BBCode.
## Hoshe #2: escape BBCode brackets in server-sourced strings.
## Araminta: passive lines get ┃ prefix + desaturated colours.
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
## Entity-anchored entries resolve display name and color from _entity_display.
func _format_entry(entry: Dictionary, alpha: float) -> String:
var speaker: String
var target: String
var speaker_color: Color
var target_color: Color
var involves_player: bool
if entry.has("speaker_id"):
# Entity-anchored entry: resolve from _entity_display registry
var sp_data: Dictionary = _entity_display.get(entry["speaker_id"], {})
var tg_data: Dictionary = _entity_display.get(entry.get("target_id", -1), {})
speaker = _escape_bbcode(sp_data.get("name", "?"))
target = _escape_bbcode(tg_data.get("name", "?"))
var sp_ci: int = sp_data.get("color_index", -1)
var tg_ci: int = tg_data.get("color_index", -1)
if sp_ci >= 0 and not _npc_colors.is_empty():
speaker_color = _enforce_contrast(_npc_colors[sp_ci % _npc_colors.size()])
else:
speaker_color = _color_for_name(sp_data.get("name", "?"))
if tg_ci >= 0 and not _npc_colors.is_empty():
target_color = _enforce_contrast(_npc_colors[tg_ci % _npc_colors.size()])
else:
target_color = _color_for_name(tg_data.get("name", "?"))
involves_player = false # Overheard entries never involve the player directly
else:
# Legacy string-keyed entry (player dialogue, backward compat)
speaker = _escape_bbcode(entry.get("speaker", "?"))
target = _escape_bbcode(entry.get("target", "?"))
speaker_color = _color_for_name(entry.get("speaker", "?"))
target_color = _color_for_name(entry.get("target", "?"))
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
var text: String = _escape_bbcode(entry.text)
var is_passive: bool = entry.is_passive
# Desaturate passive name colours (Araminta review)
if is_passive:
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
var sc := _color_with_alpha(speaker_color, alpha)
var ac := _color_with_alpha(_arrow_color, alpha)
var tc := _color_with_alpha(target_color, alpha)
var txc := _color_with_alpha(_speech_color, alpha)
var prefix := PASSIVE_GLYPH if is_passive else ""
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
if involves_player and not is_passive:
# Just "Speaker: text" or "You: text"
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
prefix, sc, speaker, txc, text
]
else:
# Full "Speaker → Target: text" for overheard
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
prefix, sc, speaker, ac, tc, target, txc, text
]
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]")
## Get a stable color for a character name, with contrast floor enforcement.
func _color_for_name(char_name: String) -> Color:
if char_name == PLAYER_NAME:
return _player_color
if _npc_colors.is_empty():
return _speech_color
var idx := absi(char_name.hash()) % _npc_colors.size()
var color := _npc_colors[idx]
return _enforce_contrast(color)
## Enforce minimum luminance so name colours remain readable against dark BG (Araminta #3).
static func _enforce_contrast(color: Color) -> Color:
var lum := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
if lum < MIN_CONTRAST_LUMINANCE:
var boost := MIN_CONTRAST_LUMINANCE / maxf(lum, 0.001)
return Color(
minf(color.r * boost, 1.0),
minf(color.g * boost, 1.0),
minf(color.b * boost, 1.0),
color.a
)
return color
## Desaturate a colour by a factor (0.0 = no change, 1.0 = full greyscale).
static func _desaturate(color: Color, amount: float) -> Color:
var grey := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
return Color(
lerpf(color.r, grey, amount),
lerpf(color.g, grey, amount),
lerpf(color.b, grey, amount),
color.a
)
## Convert a Color to a hex string with alpha baked in.
static func _color_with_alpha(base: Color, alpha: float) -> String:
return Color(base.r, base.g, base.b, base.a * alpha).to_html(true)
# -- Entry expiry --
## Remove fully expired entries. Mark dirty if any fading entries exist.
## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
func _expire_entries() -> void:
if _log_entries.is_empty():
return
var now := Time.get_ticks_msec()
var total_lifetime_msec: int = int((_entry_lifetime + _entry_fade) * 1000.0)
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]
if entry.pinned:
break # Pinned entries never expire
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)
removed = true
# Check remaining entries for fading state
if not has_fading:
for entry in _log_entries:
if not entry.pinned:
var age: int = now - entry.timestamp_msec
if age > int(_entry_lifetime * 1000.0):
has_fading = true
break
if removed or has_fading:
_log_dirty = true
if removed and _log_entries.is_empty() and not _in_player_conversation:
hide_dialogue()
# -- Visibility --
## No-op — panel is always visible as a permanent insert UI element.
func _ensure_visible() -> void:
pass
func _clear_options() -> void:
for ctrl in _option_controls:
if is_instance_valid(ctrl):
@@ -238,12 +622,12 @@ func _clear_options() -> void:
_option_is_confrontation.clear()
# Hover callbacks — closures that capture the label reference.
static func _make_hover_on(label: RichTextLabel) -> Callable:
# Hover callbacks
static func _make_hover_on(label: Control) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
static func _make_hover_off(label: RichTextLabel) -> Callable:
static func _make_hover_off(label: Control) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
+7 -5
View File
@@ -20,9 +20,9 @@ anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -416.0
offset_left = -320.0
offset_top = -200.0
offset_right = 416.0
offset_right = 320.0
grow_horizontal = 2
grow_vertical = 0
@@ -37,12 +37,14 @@ theme_override_constants/margin_bottom = 14
layout_mode = 2
theme_override_constants/separation = 10
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
[node name="DialogueLog" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
text = ""
fit_content = true
scroll_active = false
fit_content = false
scroll_active = true
scroll_following = true
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
+74 -2
View File
@@ -19,10 +19,12 @@ const LABEL_GAP := 2
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
const ENTITY_OFFSET := Vector2(0, -12) # nudge above entity sprite center
var _showing: bool = false
var _insert_active: bool = true
var _current_target_id: int = -1
var _entity_world_pos: Vector2 = Vector2.ZERO # cached world tile position of target
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
@@ -37,6 +39,21 @@ func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _process(_delta: float) -> void:
if _showing:
_update_screen_position()
queue_redraw()
func _draw() -> void:
if not _showing or _verb_labels.is_empty():
return
var pad := 6.0
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)
func update_from_state() -> void:
# D-055: Sprint stance suppresses interaction list
if GameState.player_stance == "Sprint":
@@ -70,6 +87,7 @@ func update_from_state() -> void:
_current_target_id = entity_id
_verb_items = sorted
_selected_index = 0
_cache_entity_position()
_rebuild_labels()
_show()
@@ -84,15 +102,46 @@ func _rebuild_labels() -> void:
for i in range(_verb_items.size()):
var verb: Dictionary = _verb_items[i]
var lbl := Label.new()
lbl.text = verb.get("label", "")
lbl.text = " %s " % verb.get("label", "")
lbl.add_theme_font_size_override("font_size", 14)
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
lbl.mouse_filter = Control.MOUSE_FILTER_STOP
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
var idx := i
lbl.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_select_and_interact(idx)
)
lbl.mouse_entered.connect(func(): _hover_index(idx))
lbl.mouse_exited.connect(func(): _unhover_index(idx))
_vbox.add_child(lbl)
_verb_labels.append(lbl)
## Cache the target entity's world tile position from GameState.visible_entities.
func _cache_entity_position() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
_entity_world_pos = Vector2(entity.x, entity.y)
return
## 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:
var camera := get_viewport().get_camera_2d()
if camera == null:
return
var viewport_size := get_viewport_rect().size
var cam_center := camera.get_screen_center_position()
var zoom: Vector2 = camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
var world_px := _entity_world_pos * Constants.TILE_SIZE
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
# Anchor above the entity, centered horizontally
position = screen_pos + ENTITY_OFFSET * zoom - Vector2(size.x / 2.0, size.y)
func _show() -> void:
if _showing:
return
@@ -161,6 +210,29 @@ func get_verb_labels() -> Array:
return labels
func _hover_index(idx: int) -> void:
_selected_index = idx
_update_label_colors()
func _unhover_index(_idx: int) -> void:
pass # keep last hover highlighted
func _select_and_interact(idx: int) -> void:
if idx < 0 or idx >= _verb_items.size():
return
_selected_index = idx
verb_selected.emit(_verb_items[idx].get("kind", ""), _current_target_id)
func _update_label_colors() -> void:
for i in range(_verb_labels.size()):
if is_instance_valid(_verb_labels[i]):
_verb_labels[i].add_theme_color_override(
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _showing:
+150 -35
View File
@@ -1,48 +1,163 @@
extends Control
# Internal monologue display (per D-015)
# Shows character's internal thoughts as text overlay
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
# Per Tyre architecture review, Sprint 14.
#
# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer.
# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is
# dropped when the queue is full and an equal-or-higher-priority line arrives
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
@onready var text_panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
const MAX_VISIBLE: int = 3
const MAX_QUEUE: int = 5
const STAGGER_SEC: float = 0.15
const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = {
"lattice_augmented": { # detective
"standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"),
},
"lattice_baseline": { # smuggler
"standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"),
},
}
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
@onready var _vbox: VBoxContainer = $VBoxContainer
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
var fade_timer: float = 0.0
var fade_duration: float = 5.0 # Display duration before fade
var is_visible: bool = false
var _active_tween: Tween = null
func _ready() -> void:
print("MonologueDisplay: Initialized")
text_panel.modulate.a = 0.0
is_visible = false
pass
func _process(delta: float) -> void:
# Auto-fade after display
if is_visible:
fade_timer += delta
if fade_timer >= fade_duration:
_fade_out()
# Expire visible lines
for slot in _visible.duplicate():
slot.expire_timer -= delta
if slot.expire_timer <= 0.0:
_retire_slot(slot)
# Show internal monologue text
func show_monologue(text: String, duration: float = 5.0) -> void:
text_label.text = text
fade_duration = duration
fade_timer = 0.0
is_visible = true
# Drain queue into available visible slots (one per stagger interval)
if not _queue.is_empty() and _visible.size() < MAX_VISIBLE:
var now := float(Time.get_ticks_msec())
if now >= _next_fade_in_msec:
var next: Dictionary = _queue.pop_front()
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
# Cancel any active tween before starting a new one
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3)
# Fade out the monologue
func _fade_out() -> void:
if not is_visible:
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_line(text, duration, priority, is_urgent, profile)
else:
_enqueue(text, duration, priority, is_urgent, profile)
is_visible = false
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5)
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
_vbox.add_child(line_node)
var slot := {
node = line_node,
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
priority = priority,
tween = null,
}
_visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
line_node.modulate.a = 0.0
var tween := create_tween()
slot.tween = tween
var target_opacity := 1.0 if is_urgent else 0.85
tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC)
func _retire_slot(slot: Dictionary) -> void:
_visible.erase(slot)
var node: Node = slot.node
var t: Tween = slot.tween
if t and t.is_valid():
t.kill()
var tween := create_tween()
tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC)
tween.tween_callback(node.queue_free)
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile})
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
else:
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
var lowest := _lowest_priority_idx()
if priority >= _queue[lowest].priority:
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
# else: incoming line is strictly lower priority — silently drop; no sort needed
func _lowest_priority_idx() -> int:
var idx := 0
for i in range(1, _queue.size()):
if _queue[i].priority < _queue[idx].priority:
idx = i
return idx
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
else palette.get("standard", _FALLBACK_STANDARD)
var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4)
container.add_theme_constant_override("margin_right", 4)
container.add_theme_constant_override("margin_top", 2)
container.add_theme_constant_override("margin_bottom", 2)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
label.scroll_active = false
label.add_theme_font_size_override("normal_font_size", 13)
# Escape [ to prevent BBCode injection from server-sourced text.
# [lb] is Godot's BBCode entity for a literal left bracket.
var safe_text := text.replace("[", "[lb]")
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text]
container.add_child(label)
return container
+13 -27
View File
@@ -2,37 +2,23 @@
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
; Monologue display area — bottom-left of viewport.
; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7).
; Lines are created dynamically inside VBoxContainer by monologue_display.gd.
[node name="MonologueDisplay" type="Control"]
layout_mode = 3
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -150.0
grow_horizontal = 2
layout_mode = 1
anchor_left = 0.05
anchor_top = 0.75
anchor_right = 0.55
anchor_bottom = 0.98
grow_horizontal = 1
grow_vertical = 0
clip_contents = true
mouse_filter = 2
script = ExtResource("1_monologue")
[node name="PanelContainer" type="PanelContainer" parent="."]
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_left = 100.0
offset_right = -100.0
offset_bottom = 120.0
grow_horizontal = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
text = "Internal monologue will appear here..."
fit_content = true
scroll_active = false
anchor_bottom = 1.0
theme_override_constants/separation = 4
+159
View File
@@ -0,0 +1,159 @@
extends Control
## #528: Audio settings dialog — 5-bus volume sliders.
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
## Volumes persist via AudioManager._save_prefs() on each slider change.
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
const TITLE_COLOR := Color("#4a9ebb")
const FONT_SIZE := 14
const BOX_WIDTH := 460
const BOX_HEIGHT := 340
const PADDING := 20
const ROW_HEIGHT := 36
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
const BUS_ROWS: Array = [
["Music", "Music"],
["Ambient", "Ambient"],
["World SFX", "WorldSFX"],
["Player Actions", "PlayerActions"],
["UI Sounds", "UISounds"],
]
var _active: bool = false
var _container: VBoxContainer = null
signal closed
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func open() -> void:
if _active:
return
_active = true
visible = true
_build_ui()
queue_redraw()
func close() -> void:
if not _active:
return
_active = false
visible = false
_destroy_ui()
queue_redraw()
closed.emit()
func is_open() -> bool:
return _active
func _build_ui() -> void:
var vp_size := get_viewport_rect().size
var box_pos := Vector2(
(vp_size.x - BOX_WIDTH) / 2.0,
(vp_size.y - BOX_HEIGHT) / 2.0
)
_container = VBoxContainer.new()
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
_container.add_theme_constant_override("separation", 4)
add_child(_container)
for row_data in BUS_ROWS:
var label_text: String = row_data[0]
var bus_name: String = row_data[1]
var hbox := HBoxContainer.new()
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(hbox)
var label := Label.new()
label.text = label_text
label.custom_minimum_size = Vector2(150, 0)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(label)
var slider := HSlider.new()
slider.min_value = -40.0
slider.max_value = 0.0
slider.step = 0.5
slider.value = AudioManager.get_volume(bus_name)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(slider)
var db_label := Label.new()
db_label.text = _format_db(slider.value)
db_label.custom_minimum_size = Vector2(70, 0)
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
db_label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(db_label)
slider.value_changed.connect(func(value: float) -> void:
AudioManager.set_volume(bus_name, value)
db_label.text = _format_db(value)
)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 8)
_container.add_child(spacer)
# Close button
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
close_btn.pressed.connect(close)
_container.add_child(close_btn)
func _destroy_ui() -> void:
if _container:
_container.queue_free()
_container = null
func _draw() -> void:
if not _active:
return
var viewport_size := get_viewport_rect().size
# Dim overlay
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Dialog box
var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0,
(viewport_size.y - BOX_HEIGHT) / 2.0
)
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
# Title
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, PADDING + 18),
"AUDIO SETTINGS",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
return "%d dB" % int(db)
+1
View File
@@ -0,0 +1 @@
uid://cpjq8yfsnpr5m
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
[node name="SettingsDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_settings")
+1 -1
View File
@@ -5,7 +5,7 @@
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" type="Control"]
layout_mode = 3
anchors_preset = 15
anchors_preset = 0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
+288
View File
@@ -0,0 +1,288 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dialogue-line.schema.json",
"title": "Dialogue Line — canonical D-035 tag taxonomy",
"description": "Canonical single-line schema for dialogue and monologue pools. Implements the converged tag taxonomy from D-035 (6 structural + 3 selection + 2 authoring-only tags). Monologue-specific additions (character, trigger, prerequisite) are defined in $defs/monologue_extension. Mood vocabulary renamed Sprint 14 to match voice guide (anxious/frustrated/content/suspicious/warm/hostile/relieved/focused).",
"type": "object",
"required": ["id", "text", "role", "access", "trust", "situation"],
"additionalProperties": false,
"properties": {
"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."
},
"text": {
"type": "string",
"minLength": 1,
"description": "The authored line text."
},
"role": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Template-defined role slug (e.g. dock-worker, bar-owner, player_character). Not NPC name — NPC assignment is runtime."
},
"access": {
"type": "array",
"items": {
"type": "string",
"enum": ["public", "insider", "authority", "peer", "hostile"]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 1: access tiers this line is eligible for. List — a line can be eligible for multiple tiers. Hard filter."
},
"trust": {
"type": "string",
"enum": ["surface", "real", "secret"],
"description": "D-028 Layer 3: minimum trust tier required. Hard filter. Ordering: surface < real < secret."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-028 Layer 2: situations in which this line can fire. 14 v0.1 values (13 original + greeting added Sprint 8 for PC dialogue initial contact lines). NOTE: 'greeting' is not yet in server/src/content/line_pool.rs — lines using it will be skipped until Rust is updated."
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: topic tags for weighted selection. 9 v0.1 values. Optional — defaults to empty if omitted. Note: 'crime' deliberately excluded; NPCs think of it as 'cargo' or 'money'."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-028 Layer 4: mood tags for weighted selection. 8 v0.1 values. Neutral mood = omit tag (untagged lines are always eligible). Renamed Sprint 14 to match voice guide vocabulary."
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Freeform escape hatch for author intent not covered by the structured taxonomy. Not consumed by the engine selection pipeline."
},
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line. Feeds into the knowledge graph (D-041).",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368)."
},
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "D-041 confidence tier granted."
}
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character notes for content with different resonance for smuggler vs detective. NOT consumed by the engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes, context, or intent documentation. NOT consumed by the engine."
}
},
"$defs": {
"monologue_extension": {
"title": "Monologue-specific additions (D-035)",
"description": "Additional required fields for monologue lines. Applied ON TOP OF the base dialogue line schema. Pool-level character partitioning (D-032) is enforced at the pool root, not per-line.",
"type": "object",
"required": ["trigger"],
"properties": {
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "D-032: hard partition tag. Which playable character this line belongs to. Must match the parent pool's character field."
},
"trigger": {
"type": "string",
"enum": [
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this monologue line to fire. 9 v0.1 trigger types."
},
"prerequisite": {
"description": "Knowledge state gate. null = unconditional (fires whenever triggered). Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
]
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority. Higher = more likely to fire when multiple lines are eligible. Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0 (no cooldown)."
}
}
},
"fact_prerequisite": {
"type": "object",
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required for this fact."
}
}
},
"attribute_prerequisite": {
"type": "object",
"required": ["entity", "key", "value"],
"additionalProperties": false,
"properties": {
"entity": { "type": "string" },
"key": { "type": "string" },
"value": { "type": "string" }
}
},
"situation_enum": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
],
"description": "14 v0.1 situation values (D-035 + Sprint 8 amendment)."
},
"topic_enum": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
],
"description": "9 v0.1 topic values (D-035)."
},
"mood_enum": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
],
"description": "8 v0.1 mood values. Renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
}
}
}
+19 -6
View File
@@ -90,13 +90,12 @@
"items": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved",
"focused"
"anxious", "frustrated", "content", "suspicious",
"warm", "hostile", "relieved", "focused"
]
},
"uniqueItems": true,
"description": "Mood tags for selection weighting (D-035: list<enum>, v0.1 8 moods + focused added Sprint 8)"
"description": "Mood tags for selection weighting (D-035: list<enum>). 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"tags": {
"type": "array",
@@ -106,14 +105,28 @@
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line",
"required": ["fact_id", "confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
}
},
"required": ["fact_id", "confidence"]
}
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: per-character resonance notes (NOT consumed by engine)",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes (NOT consumed by engine)"
}
}
}
+144 -34
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "monologue-pool.schema.json",
"title": "Monologue Line Pool",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035). Monologue lines carry all 6 D-035 structural tags for schema compliance, but role/access/trust are fixed constants for player-character internal voice (role=player_character, access=[public], trust=surface). The engine does not gate monologue on access or trust — these tags exist for taxonomy uniformity only.",
"type": "object",
"required": ["character", "location", "lines"],
"additionalProperties": false,
@@ -10,12 +10,12 @@
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "Playable character this pool belongs to (hard partition per D-032)"
"description": "Playable character this pool belongs to (hard partition per D-032). All lines in this pool belong to this character."
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug, or 'general' for location-independent lines"
"description": "Location slug, or 'general' for location-independent lines."
},
"lines": {
"type": "array",
@@ -26,70 +26,176 @@
"$defs": {
"monologue_line": {
"type": "object",
"required": ["id", "text", "trigger"],
"required": ["id", "text", "role", "access", "trust", "situation", "trigger"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}"
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "Line text — 160 char max to fit monologue display without scrolling"
"maxLength": 256,
"description": "Line text — 256 char hard cap, aim for ≤160 to avoid line wrap"
},
"role": {
"type": "string",
"const": "player_character",
"description": "D-035 structural tag — always player_character for monologue. Monologue is the player character's internal voice."
},
"access": {
"type": "array",
"items": { "const": "public" },
"minItems": 1,
"maxItems": 1,
"description": "D-035 structural tag — always [public] for monologue. No access gating applies to internal voice."
},
"trust": {
"type": "string",
"const": "surface",
"description": "D-035 structural tag — always surface for monologue. No trust gating applies to internal voice."
},
"situation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"arrival",
"shift_start",
"shift_end",
"shift_transition",
"bar_evening",
"night_shift",
"investigation",
"confrontation",
"social",
"alone",
"emergency",
"routine",
"observation",
"greeting"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 14 v0.1 values. The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
},
"trigger": {
"type": "string",
"enum": [
"enter_location", "observe_npc", "hear_sound",
"observe_anomaly", "post_conversation", "discover_evidence",
"witness_interaction", "time_idle", "return_visit"
"enter_location",
"observe_npc",
"hear_sound",
"observe_anomaly",
"post_conversation",
"discover_evidence",
"witness_interaction",
"time_idle",
"return_visit"
],
"description": "What causes this line to fire"
"description": "What causes this line to fire. 9 v0.1 trigger types (D-035 monologue-specific tag)."
},
"prerequisites": {
"type": "object",
"description": "AND-only prerequisite conditions",
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"description": "Knowledge state gate (D-035 monologue-specific tag). null or omitted = unconditional. Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"required": ["target", "state"],
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
}
}
]
},
"topic": {
"type": "array",
"items": {
"type": "string",
"enum": [
"colleague",
"routine",
"cargo",
"money",
"trust",
"danger",
"institution",
"personal",
"investigation"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: topic tags for weighted selection. 9 v0.1 values. Optional."
},
"mood": {
"type": "array",
"items": {
"type": "string",
"enum": [
"anxious",
"frustrated",
"content",
"suspicious",
"warm",
"hostile",
"relieved",
"focused"
]
},
"uniqueItems": true,
"description": "D-035 selection tag: mood tags for weighted selection. 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority (higher = more likely to fire)"
"description": "Selection priority (higher = more likely to fire when multiple lines are eligible). Default: 5."
},
"cooldown": {
"type": "integer",
"minimum": 0,
"description": "Minimum ticks before this line can fire again"
"default": 0,
"description": "Minimum simulation ticks before this line can fire again. Default: 0."
},
"tags": {
"type": "array",
"items": { "type": "string" }
"items": { "type": "string" },
"description": "D-035 selection tag: freeform tags. Not consumed by the selection pipeline."
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: notes on how this line reads differently for smuggler vs detective. NOT consumed by engine.",
"additionalProperties": false,
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: freeform author notes. NOT consumed by engine."
}
}
},
@@ -98,10 +204,14 @@
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"fact_id": {
"type": "string",
"description": "FactId from the knowledge vocabulary (#368, D-041)."
},
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
"enum": ["suspects", "knows_of", "knows_details", "direct"],
"description": "Minimum D-041 confidence level required."
}
}
},
@@ -41,7 +41,7 @@ lines:
access: [insider]
trust: real
situation: [routine]
mood: [comfortable]
mood: [content]
topic: [cargo]
tags: [kael, ring-ops, renn, operational]
@@ -51,7 +51,7 @@ lines:
access: [insider]
trust: real
situation: [routine]
mood: [concerned]
mood: [frustrated]
topic: [danger, cargo]
tags: [kael, ring-ops, operational, caution]
@@ -66,7 +66,7 @@ lines:
access: [insider]
trust: surface
situation: [confrontation]
mood: [concerned]
mood: [frustrated]
topic: [danger]
tags: [kael, contradiction, phase-3]
@@ -76,7 +76,7 @@ lines:
access: [insider]
trust: surface
situation: [confrontation]
mood: [concerned]
mood: [frustrated]
topic: [routine]
tags: [kael, deflection, lying, phase-4]
@@ -86,7 +86,7 @@ lines:
access: [insider]
trust: surface
situation: [confrontation]
mood: [concerned]
mood: [frustrated]
topic: [trust]
tags: [kael, deflection, hostile-path, phase-4]
@@ -100,7 +100,7 @@ lines:
access: [insider]
trust: secret
situation: [confrontation, alone]
mood: [conflicted]
mood: [anxious]
topic: [personal, trust]
tags: [kael, confession, phase-5]
@@ -110,7 +110,7 @@ lines:
access: [insider]
trust: secret
situation: [confrontation, alone]
mood: [worried]
mood: [anxious]
topic: [danger, trust]
tags: [kael, confession, nils, phase-5]
@@ -120,9 +120,65 @@ lines:
access: [insider]
trust: secret
situation: [alone]
mood: [conflicted]
mood: [anxious]
topic: [personal]
tags: [kael, confession, phase-5]
knowledge_grant:
fact_id: knowledge.kael_exit_plan
confidence: knows_details
# ========================================
# LAYER 2 GREETING VARIANTS — D-028 Layer 2
# Ticket #170 (Sprint 15). situation: [greeting] fires on initial contact.
# Access tier + mood encode relationship history state.
# Context: restricted maintenance corridors. Any stranger here is immediately
# suspicious. Even trusted contacts get terse, operational greetings.
# ========================================
# first_meeting: InteractionMemory.count == 0. Any stranger in restricted corridors is a problem.
- id: maintenance-corridors_d_011
text: "This is a restricted section. Who authorized you back here?"
role: dock-worker
access: [public]
trust: surface
situation: [greeting]
mood: [focused]
topic: [danger, routine]
tags: [kael, greeting, first-meeting, layer2]
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
text: "You're on time. Clear all the way to the junction."
role: dock-worker
access: [peer, insider]
trust: surface
situation: [greeting]
mood: [content]
topic: [cargo, colleague]
tags: [kael, greeting, repeat-visit, peer-tone, layer2]
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
text: "You keep showing up back here. You have clearance for this section?"
role: dock-worker
access: [authority]
trust: surface
situation: [greeting]
mood: [suspicious]
topic: [danger, routine]
tags: [kael, greeting, repeat-visit, authority-tone, layer2]
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
text: "You followed me here."
role: dock-worker
access: [peer, insider]
trust: surface
situation: [greeting]
mood: [hostile, suspicious]
topic: [danger, trust]
tags: [kael, greeting, post-confrontation, layer2]
notes: "Layer 2 greeting — post-confrontation, restricted corridors. Accusatory, not a question. Kael feels cornered. This is where the contradiction lives."
@@ -1,2 +1,379 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: ring-operative at Maintenance Corridors
# NPC: Generic ring operative (Tier 3 template — members without individual profiles)
# Voice: two registers. Surface: maintenance worker, brief, task-focused. Real: coded, precise, never explicit.
# The crucial thing: both registers sound identical to a casual listener.
# Ring-aware: full ring membership. Knows the route, the window, the players.
# Ring code vocabulary (from Sova texture appendix):
# "running maintenance" = cover for ring operation
# "the usual pickup" = ring cargo handoff
# "cans are ready" = containers cleared for movement
# "manifest is clean" = paperwork cover confirmed
# "Drin was asking" = warning, inspection proximity
# "dead air" = Meridian surveillance gaps
# "the gap" = maintenance corridors, the route itself
# Ticket: #192 | Sprint: 12
location: maintenance-corridors
role: ring-operative
lines:
# ==========================================
# SURFACE COVER — sounds like maintenance work
# ==========================================
- id: maintenance-corridors_d_100
text: "Running maintenance on the B-7 conduit. Could be a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, b7]
dual_lens:
smuggler: "Cover phrase. He's not doing maintenance."
detective: "Maintenance worker. Conduit repair. Routine."
notes: "'Running maintenance' is the canonical ring cover phrase."
- id: maintenance-corridors_d_101
text: "Bay four access from here is faster than going around. If you're in a hurry."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, bay-four, spatial]
dual_lens:
smuggler: "He's telling me the route is faster. This is spatial information, not just courtesy."
detective: "Ring operative casually establishes corridor-to-bay-four spatial relationship. Note it."
- id: maintenance-corridors_d_102
text: "Quiet in here tonight. Good working conditions."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, ambient]
dual_lens:
smuggler: "Dead air confirmed. Meridian's not picking this up."
detective: "Routine corridor comment."
- id: maintenance-corridors_d_103
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [content]
tags: [ring-operative, surface-cover, pael, corridor-traffic]
dual_lens:
smuggler: "Pael was through. Legitimate. Corridor's been active — that means traffic covers us."
detective: "References Pael's B-7 presence without suspicion. Maintenance workers know each other's routes."
- id: maintenance-corridors_d_104
text: "C-4 access is at the far end. About three minutes from here."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, c4, spatial]
dual_lens:
smuggler: "He's giving me the timing. Three minutes to C-4 from this point."
detective: "Operative establishes C-4 proximity to maintenance corridor. That's the link."
knowledge_grant:
fact_id: location.smuggling_route
confidence: suspects
# ==========================================
# OPERATIONAL COORDINATION — insider/real only
# ==========================================
- id: maintenance-corridors_d_105
text: "Cans are ready. Bay four, per the schedule."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, cans-ready]
dual_lens:
smuggler: "Containers cleared. Bay four. We're on."
detective: "Never hears this line."
- id: maintenance-corridors_d_106
text: "Manifest is clean. Window opens in eighteen minutes."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, manifest-clean, window]
dual_lens:
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
detective: "Never hears this line."
- id: maintenance-corridors_d_107
text: "Usual pickup. Route's the same."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition, routine]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, usual-pickup]
dual_lens:
smuggler: "Same route, same handoff. No variations."
detective: "Never hears this line."
- id: maintenance-corridors_d_108
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, scan-gap, surveillance]
dual_lens:
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_109
text: "Drin was asking about B-7. Twice today."
role: ring-operative
access: [insider]
trust: real
situation: [routine, confrontation]
topic: [danger, colleague]
mood: [frustrated]
tags: [ring-operative, ring-ops, coded, drin-asking, warning]
dual_lens:
smuggler: "Drin's circling B-7. That's a warning. Push the pickup or hold it."
detective: "Never hears this line."
knowledge_grant:
fact_id: investigation.drin_inspection_pattern
confidence: suspects
- id: maintenance-corridors_d_110
text: "Dead air through here to the C-4 junction. No coverage."
role: ring-operative
access: [insider]
trust: real
situation: [routine]
topic: [routine]
mood: [focused]
tags: [ring-operative, ring-ops, coded, dead-air, surveillance]
dual_lens:
smuggler: "Full confirmation. Meridian doesn't reach this section."
detective: "Never hears this line."
knowledge_grant:
fact_id: location.surveillance_gaps
confidence: knows_details
- id: maintenance-corridors_d_111
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
role: ring-operative
access: [insider]
trust: real
situation: [shift_transition]
topic: [cargo, routine]
mood: [focused]
tags: [ring-operative, ring-ops, voss, bay-four, clearance]
dual_lens:
smuggler: "Voss is personally running bay four at 1400. That's the signal the window is clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_112
text: "Kael's on dock. He knows."
role: ring-operative
access: [insider]
trust: real
situation: [routine, shift_transition]
topic: [colleague, routine]
mood: [focused]
tags: [ring-operative, ring-ops, kael, coordination]
dual_lens:
smuggler: "Kael's in position. Ring is coordinated."
detective: "Never hears this line."
# ==========================================
# PRESSURE / WARNING SIGNALS
# ==========================================
- id: maintenance-corridors_d_113
text: "Commission eyes on the terminal floor. Not here yet."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, investigation]
topic: [danger, institution]
mood: [frustrated]
tags: [ring-operative, ring-ops, commission, warning]
dual_lens:
smuggler: "Detective is visible on the main floor. Corridors are still clear."
detective: "Never hears this line."
- id: maintenance-corridors_d_114
text: "Hold the run. Drin's in the corridor section."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation]
topic: [danger]
mood: [frustrated]
tags: [ring-operative, ring-ops, drin, abort-signal]
dual_lens:
smuggler: "Stop. Drin's in position. The window's closed."
detective: "Never hears this line."
- id: maintenance-corridors_d_115
text: "Maret filed again. Voss has it. Don't move anything tonight."
role: ring-operative
access: [insider]
trust: real
situation: [confrontation, routine]
topic: [danger, institution]
mood: [frustrated]
tags: [ring-operative, ring-ops, maret, voss, caution]
dual_lens:
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
detective: "Never hears this line."
- id: maintenance-corridors_d_116
text: "We're clean. Same time tomorrow."
role: ring-operative
access: [insider]
trust: real
situation: [shift_end, routine]
topic: [routine]
mood: [content]
tags: [ring-operative, ring-ops, run-complete, closing]
dual_lens:
smuggler: "Run's done. No issues. Tomorrow."
detective: "Never hears this line."
# ==========================================
# IF DETECTIVE SOMEHOW ENTERS — surface only
# ==========================================
- id: maintenance-corridors_d_117
text: "Restricted access back here. Service personnel only."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation, confrontation]
topic: [institution, routine]
mood: [suspicious]
tags: [ring-operative, detective-path, access-denial]
- id: maintenance-corridors_d_118
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
role: ring-operative
access: [authority]
trust: surface
situation: [investigation]
topic: [institution, routine]
mood: [suspicious]
tags: [ring-operative, detective-path, voss-redirect]
dual_lens:
smuggler: "Never encounters this."
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
- id: maintenance-corridors_d_119
text: "Nothing unusual back here. Conduit work. Same every cycle."
role: ring-operative
access: [authority, public]
trust: surface
situation: [investigation]
topic: [routine]
mood: [suspicious]
tags: [ring-operative, detective-path, deflection]
# ==========================================
# GENERATION PASS — surface cover variants
# Surface-only: covers routine corridor presence without operational tells.
# ==========================================
- id: maintenance-corridors_d_120
text: "Power relay's been cycling weird. I'm watching it."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_121
text: "Pael was supposed to handle B-7. He didn't show."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine, colleague]
mood: [content]
tags: [ring-operative, surface-cover, pael, generation-pass]
- id: maintenance-corridors_d_122
text: "Watch the floor here. Drain panel's been loose three cycles."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_123
text: "Airflow's better down the B-section. C-section's got the recycler issue."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, spatial, generation-pass]
- id: maintenance-corridors_d_124
text: "You want the service bay, it's left at the junction. Don't go right — that's the restricted access."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, navigation, generation-pass]
dual_lens:
smuggler: "He's directing people away from the route. Social traffic management."
detective: "Operative redirects me away from C-4 access junction instinctively."
- id: maintenance-corridors_d_125
text: "Long shift. These corridors look the same after a while."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine, shift_end]
topic: [routine]
mood: [frustrated]
tags: [ring-operative, surface-cover, generation-pass]
- id: maintenance-corridors_d_126
text: "Scan's clear. I checked it an hour ago."
role: ring-operative
access: [public, peer]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [ring-operative, surface-cover, generation-pass]
@@ -0,0 +1,77 @@
# Dialogue: transit-worker at Maintenance Corridors
# NPC: Tev Osel (Tier 3, NOBODY/CIVILIAN)
# Voice: patient, mildly distracted, confused why anyone is asking him anything.
# KEY LINE: maintenance-corridors_d_001 — sounds like surveillance positioning inquiry
# Actually: Tev missed Kosse at the corridor junction and is looking for them
# Ticket: #307 | Sprint: 12
location: maintenance-corridors
role: transit-worker
lines:
# ==========================================
# "SEEMS IMPORTANT BUT ISN'T" LINE
# Sounds like Tev is checking who came through a surveillance point.
# Actually: Tev missed his partner at the regular meeting spot.
# ==========================================
- id: maintenance-corridors_d_001
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [colleague, personal]
mood: [content]
tags: [tev, seems-important-but-isnt, waiting, kosse]
dual_lens:
smuggler: "Someone asking who came through the corridor. Tev. Harmless — checking for a partner."
detective: "NPC at corridor junction, tracking who passed through. Possible lookout. Follow up: who is Kosse?"
notes: >
THE key flat NPC line for Tev. Sounds like Tev monitors corridor traffic
for operational reasons. Is someone who missed a meeting time with their
partner. The detective's suspicion is reasonable; it resolves to nothing.
# ==========================================
# ROUTINE LINES
# ==========================================
- id: maintenance-corridors_d_002
text: "They shifted the freight route again. Always forget which junction."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine, cargo]
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_003
text: "Just on break. Out of your way in a minute."
role: transit-worker
access: [public]
trust: surface
situation: [routine]
topic: [routine]
mood: [content]
tags: [tev, incidental]
- id: maintenance-corridors_d_004
text: "Late freight route runs long. Can't do anything about it."
role: transit-worker
access: [public]
trust: surface
situation: [routine, night_shift]
topic: [routine, cargo]
mood: [content]
tags: [tev, atmospheric]
- id: maintenance-corridors_d_005
text: "Waiting for someone. Shouldn't be long."
role: transit-worker
access: [public]
trust: surface
situation: [routine, observation]
topic: [personal]
mood: [content]
tags: [tev, waiting]

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