Compare commits

..
32 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 46cfec4183 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:06:14 +01:00
jpmschweitzerandClaude Opus 4.6 0472dcb86c feat(copy): triangle activation proximity monologue lines (#597)
Author 4 smuggler and 5 detective monologue lines that fire when
the player observes triangle anchor NPCs post-TriangleActivated.

Smuggler (Kael Davan): physical observation → rationalization →
doubt → sensory confirmation. Contracted, personal, friend-arc.

Detective (Sera Venn/Torek): pattern recognition → deviation →
hypothesis → inference → procedural next step. Analytical,
institutional, evidence-cataloguing.

All lines: situation: [triangle_activated], trigger: observe_npc,
cooldown: 9999, priority: 8, prerequisite: npc_in_los: true.

Schema updated: triangle_activated added to situation enum,
npc_in_los added to prerequisites in monologue-pool.schema.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:05:50 +01:00
jpmschweitzerandClaude Opus 4.6 fc24de6128 fix(client): test harness tile types, bug report screenshot, time_display maxf
TestHarness: remove deprecated tiles/visible_positions keys, add tile
type (floor/wall/door) to visible_tiles, expand radius to 5. Bug report
dialog: capture viewport screenshot before showing overlay, save as
screenshot.png in report bundle. time_display: use maxf() instead of
max() to match float argument types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:18 +01:00
jpmschweitzerandClaude Opus 4.6 ac763fef97 feat(engine): live server visual tests and gauntlet snapshot replay
Add live server lifecycle to tests/run-visual (start/stop server per
scenario, parse LISTENING:{port}). Add MessagePack snapshot replay to
visual_capture.gd via Protocol.decode_snapshot() — exercises the full
client pipeline from wire bytes to rendered fog. Three replay scenarios
(hub_spawn, fog_theater, hub_after_movement) plus one live scenario
(fog_live_hub). Add gen_gauntlet_fixtures.rs to produce .msgpack fixtures
from the Gauntlet test world. Add max_diff_pct threshold to visual-diff.
Makefile: add fixtures-gauntlet target, fix build-client double-import,
preserve .godot cache in clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:09 +01:00
jpmschweitzerandClaude Opus 4.6 2189b00c6f fix(client): move fog blur to CPU pipeline, fix GL compat bilinear on RGBA8
Replaces GPU 7×7/5×5 Gaussian blur (98 texture reads/px) with CPU-side
Gaussian blur (sigma 2.0) + 4× bilinear upscale + RGBA8 convert in
fog_state.gd. GL compatibility mode doesn't bilinear-filter R8 textures;
RGBA8 at 4× resolution resolves this. Squared exp_fade at the
explored/unexplored boundary keeps fog opaque near tile content edges,
fixing the staircase artifact. Shader now does 2 texture reads per pixel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:40:57 +01:00
jpmschweitzerandClaude Opus 4.6 a75d9f6c08 chore(meta): plan Sprint 24: Signal
10 tickets across server (6), client (3), copy (1).
Capstone sprint for v0.1 — everything converges on a full
playthrough from main menu through storyteller activation.

Closed stale epics: #38, #369, #455, #575, #596.
Sprint goal: wire TriangleActivated into player-visible signal,
thread character archetype through session lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:42:30 +01:00
jpmschweitzerandClaude Opus 4.6 f7852b93ac chore(meta): release v0.1.23
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:27:06 +01:00
jpmschweitzer 69d16e1be3 Merge remote-tracking branch 'origin/visual' 2026-03-04 23:24:33 +01:00
jpmschweitzerandClaude Opus 4.6 1037ea2bf7 docs(meta): add testing preferences to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:16:22 +01:00
jpmschweitzerandClaude Opus 4.6 895a77ae68 fix(assets): address PR #83 review — tile map corrections
Review fixes:
- Terminal: fix sightlines comment col 22 → col 28 (actual window position)
- Last Shift: fix door comment col 21 → col 22, add spatial features
  (bar counter W-stubs cols 1-2, table clusters, card table, ticker mount)
- Maintenance corridors: extend transition corridor from 26 to 40 tiles
  per D-093 spec (grid now 58x6)
- Gate ground: expand customs zone from 8 to 10 rows per D-093 spec,
  correct freight (20 tiles west) / ped (10 tiles east) layout with
  6-tile corridor between (grid now 40x34)
- Gate gallery: add staircase entrance tile (col 1 row 0 = R)
- District description: update from "Three social sites" to five locations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:10:48 +01:00
jpmschweitzer f82f83ae0e Merge remote-tracking branch 'origin/maintenance'
# Conflicts:
#	.claude/skills/sprint-start/SKILL.md
2026-03-04 23:08:10 +01:00
jpmschweitzerandClaude Opus 4.6 749e16f379 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:01:26 +01:00
jpmschweitzerandClaude Opus 4.6 1db3677b30 chore(meta): reframe worktree boundaries as team identity
Replace worktree-centric language with team-centric framing across
CLAUDE.md and skills. Agents now identify by $WORKTREE_TEAM env var
instead of resolving git internals. This prevents agents from
following .git pointers back to the main repo and crossing boundaries.

- CLAUDE.md: rename section to "Team boundaries", reference $WORKTREE_TEAM
- sprint-start: add TEAM BOUNDARY rule to agent spawn prompt
- sprint-plan: replace "worktree-relative paths" with "relative paths only"
- pr-review: replace "worktree" with "team directory", note cross-dir
  reading is a main-team privilege only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:01:13 +01:00
jpmschweitzerandClaude Opus 4.6 610dd78ff1 feat(assets): author tile maps for Sova Transit District (#582, #583)
Complete tile data for all five locations in the transit district:
- The Terminal (44x28, z=1): logistics hub with supervisor office LOS window
- The Last Shift (34x22, z=1): bar with corner booth and back room alley exit
- Maintenance Corridors (44x6, z=0): restricted storage, hatch room, transition corridor
- Gate Ground (40x32, z=1): aperture chamber, staging, customs lanes, concourse
- Gate Gallery (32x10, z=2): Commission-only observation gallery

Gate corridor split into two files (gate-ground, gate-gallery) because
the Location struct supports only one tile_bounds per file.

Updated district.yaml locations list with gate-ground and gate-gallery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:47:43 +01:00
jpmschweitzerandClaude Opus 4.6 1f52f0b00d chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:32:07 +01:00
jpmschweitzerandClaude Opus 4.6 388df8000a fix(client): debug console review fixes — D-088 pause, settings state, response guard
- Add D-088 Overlay pause/unpause signals to DebugConsole, wire in main.gd
  so sim does not advance while typing debug commands
- Settings dialog reads live DebugConsole.is_enabled() instead of ConfigFile
  directly, preventing checkbox/state divergence
- append_response respects disabled state — no auto-open when user disabled
  console via settings
- tp command warns on invalid z value instead of silently defaulting to 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:31:52 +01:00
jpmschweitzerandClaude Opus 4.6 1feec914b7 fix(client): bump PROTOCOL_VERSION 17 → 18 to match server
Server #580 bumped to 18 for debug_response field. Client was still
at 17, causing every snapshot to be rejected — game unplayable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:29:38 +01:00
jpmschweitzerandClaude Opus 4.6 e9dba609c9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:33:32 +01:00
jpmschweitzerandClaude Opus 4.6 33b26c1a15 test(client): add BoundaryWall fog tests and document tile_renderer behavior (#585)
4 new tests in test_fog_sprint22.gd verify BoundaryWall tiles populate
boundary_positions (not visible_positions), get VIS_FORWARD without
EXP_VISIBLE, stay EXP_UNEXPLORED after leaving LOS, and clear on new
snapshot. Comment in tile_renderer.gd documents implicit rendering path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:33:08 +01:00
jpmschweitzerandClaude Sonnet 4.6 475280191c fix(ui): render LOS boundary wall tiles through fog without marking explored (#585)
- game_state.gd: add boundary_positions Dictionary field; BoundaryWall tiles from
  visible_tiles go to boundary_positions instead of visible_positions — rendered by
  tile_renderer but not tracked as explored fog memory
- fog_state.gd: update_from_state() writes VIS_FORWARD for boundary_positions so fog
  lifts over margin wall content; boundary tiles excluded from exploration step so they
  don't persist as EXP_EXPLORED when player turns away
- tile_renderer.gd: no changes needed — renders all visible_tiles by type, sector-agnostic
- test_fog_shader.gd: 4 new tests — boundary excluded from visible_positions, tracked in
  boundary_positions, cleared each snapshot, fog lifts to VIS_FORWARD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:32:32 +01:00
jpmschweitzerandClaude Sonnet 4.6 c3abf32185 feat(ui): add in-game debug console with tilde toggle and command dispatch (#581)
- debug_console.gd: new ModalLayer Control — tilde key toggles bottom-40% panel,
  command history (up/down), SimBridge dispatch for all DebugCommandKind variants:
  ticks, contaminate, tp, activate, triangle, npc, triangles, pop, status, help
- debug_console.tscn: minimal scene node; UI built programmatically in _ready()
- input_mapper.gd: DEBUG_COMMAND action added to Action enum
- sim_bridge.gd: DEBUG_COMMAND → "DebugCommand" wire mapping
- protocol.gd: v18 debug_response decode (command, text, success fields)
- game_state.gd: debug_response field + apply_snapshot one-shot handling
- main.gd: @onready ref, router registration, _consume_debug_response(), settings signal
- settings_dialog.gd: debug_console_toggled signal + CheckButton toggle row (+36px height),
  reads initial state from user://settings.cfg; CheckButton state loaded from PREFS_PATH
- main.tscn: DebugConsole node on ModalLayer, load_steps 27→28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:50:23 +01:00
jpmschweitzerandClaude Sonnet 4.6 60d2c5bb90 fix(ui): correct Sprite2D/self_modulate assertions in P2 client tests (#574)
Same Sprite2D correction applied to test_client_p2.gd entity color tests
(Terrain, Player) — ColorRect was replaced with Sprite2D in entity_renderer.gd.
Minor comment clarification in test_rendering.gd rotation test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:50:08 +01:00
jpmschweitzerandClaude Sonnet 4.6 d30ab62bc0 fix(ui): clear color registry on conversation end, add speaker color tests (#573)
- Reset _npc_entity_colors/_npc_entity_id/_next_npc_color in _end_player_conversation()
  to prevent palette exhaustion across long sessions with many unique NPCs
- Re-enforce contrast floor after passive desaturation (_enforce_contrast after _desaturate)
- Add TestDialogueSpeakerColors suite: palette allocation, entity reuse, reset, fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:50:02 +01:00
jpmschweitzerandClaude Sonnet 4.6 974bb2b28e feat(ui): bind dialogue speaker colors to entity identity (#573)
Maintains Dict[entity_id → Color] in dialogue_box for player conversations.
On first encounter, assigns a round-robin palette color; reuses on subsequent lines.
Eliminates position-based name-hash coloring for player dialogue.

Changes:
- Add _npc_entity_colors dict, _npc_entity_id, _next_npc_color fields
- Add _assign_npc_color(entity_id) — registers palette color on first encounter
- show_dialogue: accept npc_entity_id param, register entity color
- append_line: optional speaker_entity_id/target_entity_id stored in log entries
- append_player_line: pass _npc_entity_id as target_entity_id
- append_dialogue_response: accept entity_id, register, pass to append_line
- _format_entry else branch: look up _npc_entity_colors before name-hash fallback
- main.gd: pass _last_dialogue_npc_id to show_dialogue, speaker_entity_id to append_dialogue_response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:05:15 +01:00
jpmschweitzerandClaude Sonnet 4.6 87b5cbb6c2 fix(ui): correct entity renderer test assertions for Sprite2D and zero offsets (#574)
Fixes 7 pre-existing failures in test_entity_renderer tests:
- ColorRect → Sprite2D cast; .color → .self_modulate for D-033 color checks
- Position offset: (TILE_SIZE-24)/2 → EntityRenderer.ENTITY_OFFSET_{X,Y} (0.0)
- Rotation accuracy: expected values updated for raw un-normalised Godot rotation
Also adds SoundIndicatorRenderer to global_script_class_cache.cfg so test_rendering.gd parses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:04:18 +01:00
jpmschweitzerandClaude Opus 4.6 ac68ec6eef fix(simulation): address PR #81 review — critical and high-priority issues
Critical fixes:
- storyteller: replace .expect() with guard + log in activation_pass (Hoshe #1)
- content/loader: validate inverted tile_bounds before iteration (Hoshe #C)
- save_io: persist ActivationState on save/load (Tyre #7)

High-priority fixes:
- storyteller: f64 intermediate for observation_time_ticks scoring (Hoshe #A)
- storyteller: deduplicate copresent entities before scoring (Hoshe #B)
- storyteller: skip activation_pass at tick 0 (Hoshe #G)
- storyteller: explicit .before(advance_tick) ordering (Tyre #9)
- save_state: insert EngagementRecord on NPC deserialize (Tyre #10)
- save_io: reset TriangleActivatedQueue + MovementHistoryBuffer on load (Tyre #8)
- debug: validate teleport target walkability (Hoshe #E)
- debug: reject SkipToContamination when tick past delay (Hoshe #F)
- debug: DebugEnabled defaults to cfg!(debug_assertions) (Hoshe #3)
- debug: log when response overwritten (Hoshe #2)
- content/loader: error on tile dimension mismatch (Hoshe #5)
- content/types: DistrictMeta.description optional (Hoshe #D)
- types: version docs updated to v18 (Tyre #1, #2, #3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:40:59 +01:00
jpmschweitzerandClaude Opus 4.6 eb64f23b77 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:13:12 +01:00
jpmschweitzerandClaude Opus 4.6 c797a72fc2 chore(simulation): update tests and fixtures for Sprint 23
- Add debug_response: None to all ObserverSnapshot constructors in
  integration tests
- Bump PROTOCOL_VERSION assertion 17 → 18 in serialization tests
- Regenerate golden proof_room_tick_10.json (BoundaryWall tiles)
- Regenerate client msgpack fixtures for new snapshot fields
- Fix debug.rs resource optionality (Option<ResMut> for
  ContaminationActive/EventQueue)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:12:19 +01:00
jpmschweitzerandClaude Opus 4.6 c30197db0e feat(simulation): add debug console server and LOS boundary wall margin
Debug and LOS tracks for Sprint 23 (#580, #584):

- DebugCommandKind enum with 10 variants (AdvanceTicks,
  SkipToContamination, TeleportToPosition, ForceContaminationActivate,
  InspectNpc, ListTriangles, ListPopulation, GetContaminationStatus,
  TeleportToLocation, ForceTriangleActivation)
- DebugResponsePayload on ObserverSnapshot, handle_debug_commands
  system gated by DebugEnabled resource
- PROTOCOL_VERSION bumped 17 → 18
- VisibilitySector::BoundaryWall variant — 1-tile wall margin beyond
  LOS boundary included in visible_tiles (not exploration/memory)
- compute_boundary_walls() pass in NaturalVision after FOV+cone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:12:08 +01:00
jpmschweitzerandClaude Opus 4.6 50f5d6c22e feat(simulation): add storyteller engagement tracking and activation pass
Storyteller chain for Sprint 23 (#570, #571, #572, #579):

- EngagementRecord component: per-NPC observation_time_ticks,
  conversation_count, monologue_trigger_count — incremented by
  perception, dialogue, and monologue systems
- MovementHistoryBuffer resource: ring buffer of player positions
  over last 3000 ticks with npcs_copresent_in_window() query
- Lifecycle rules: single activation per session, no concurrency,
  no cooldown, terminal resolution constants
- activation_pass() system: gate check, proximity query, engagement
  scoring, unentangled-NPC routing, module selection, emits
  TriangleActivatedEvent on 10-tick cadence after contamination

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:11:54 +01:00
jpmschweitzerandClaude Opus 4.6 7fbfc5ae65 feat(simulation): add tile-type layer, content loader, and chunk streaming
Spatial chain for Sprint 23 (#576, #577, #578):

- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with
  set_tile_kind/tile_kind API, backward-compatible with existing
  is_walkable/set_walkable
- Location YAML tile format: tiles as string arrays (F/W/V/R chars),
  load_location_tiles() stamps tile data onto WalkabilityMap from
  ContentStore on production startup
- Chunk streaming system: ChunkLoadRadius + ChunkStreamingCadence
  resources, loads/unloads chunks around player position on cadence.
  v0.1 radius covers full district (no streaming stutter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:11:42 +01:00
jpmschweitzerandClaude Opus 4.6 b6c255c8cb chore(meta): plan Sprint 23: Terrain
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:28:48 +01:00
110 changed files with 16269 additions and 287 deletions
+9 -9
View File
@@ -74,35 +74,35 @@ 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 worktree (see below).
2. Read source files directly from the team directory (see below).
For large diffs (>1000 lines of source), provide **source files** rather than
raw diff to reviewers — cleaner context, better reviews.
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
worktrees. Each team branch is checked out at:
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
is checked out in its own directory at:
```
/var/mnt/data/projects/settled-reach/<branch>/
```
For example, the `copy` branch lives at:
For example, the `copy` team directory is at:
```
/var/mnt/data/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
from the team directory 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:
team directory. Example instruction for agents:
```
Read the changed files from the branch worktree. The branch is checked
Read the changed files from the team directory. The branch is checked
out at: /var/mnt/data/projects/settled-reach/<branch>/
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
@@ -110,8 +110,8 @@ use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal
```
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).
directory (they're identical to main, but using the team directory path
keeps agents grounded in the correct location).
### 4. Spawn reviewers in parallel
@@ -2,20 +2,24 @@
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**All agents read from worktree paths.** Each branch is checked out at:
**All reviewer agents read from team directories.** Each team branch is
checked out in its own directory at:
`/var/mnt/data/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.
Tell every reviewer agent to read source files from the team directory
using the Read tool. Include the directory 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 directory.
Note: cross-directory reading is only permitted for review agents spawned
from the `main` team. Team agents must stay within their own directory.
## Code reviews (`server`, `client`, `ci`)
**Hoshe (Code Quality)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read source files from the worktree, then review for:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read source files from the team directory, then review for:
- Correctness and bug risks
- Error handling gaps
- Test coverage (are new features tested?)
@@ -25,8 +29,8 @@ can read directly from the worktree.
**Tyre (Architecture)**
- `subagent_type`: `tyre`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
first, then review for:
- Architectural consistency with project decisions
- API/interface design quality
@@ -38,8 +42,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- 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:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- Formatting consistency (markdown, file naming, frontmatter)
- Broken references or links
- Spelling and grammar
@@ -48,9 +52,9 @@ can read directly from the worktree.
**Paula (Narrative Depth)**
- `subagent_type`: `paula`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, commit log, and
- Prompt: Provide the team directory 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:
files from the team directory 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?
@@ -59,9 +63,9 @@ can read directly from the worktree.
**Miri (World Consistency)**
- `subagent_type`: `miri`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, commit log, and
- Prompt: Provide the team directory 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:
files from the team directory 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
@@ -72,8 +76,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- 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:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- File format and naming conventions
- Asset organization and directory structure
- Missing or broken references in scene/resource files
@@ -81,7 +85,7 @@ can read directly from the worktree.
**Araminta (Art Direction)**
- `subagent_type`: `araminta`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
- Prompt: Provide the team directory 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
@@ -94,8 +98,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- 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:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- File format and naming conventions
- Audio asset organization and directory structure
- Missing or broken references
@@ -103,8 +107,8 @@ can read directly from the worktree.
**Ozzie (Player Experience)**
- `subagent_type`: `ozzie`, `model`: `sonnet`
- 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
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Ozzie to read all files from the team directory using the Read tool, then
review for:
- Emotional impact — does the audio enhance the moment?
- Atmosphere and tone — does it feel like the Commonwealth?
+20 -7
View File
@@ -154,13 +154,12 @@ Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
Each team branch is checked out in its own worktree, and each worktree
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the worktree/git root. Example:
**IMPORTANT — relative paths only:** Each team works in its own directory
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the working directory. Example:
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
paths that navigate outside the git root (`../sibling-worktree/...`).
Agents must stay within the git root they are running in.
paths that navigate outside (`../sibling-dir/...`).
Agents must stay within their team's working directory.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
@@ -193,7 +192,20 @@ tooling/db/ticket sprint assign <ticket_id> <sprint_id>
The sprint stays in `planning` status until explicitly activated via
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
### 8. Present summary
### 8. Commit and push
Stage the briefing files and any other changes (db backup, closed tickets),
then commit and push so worktree branches can pull the planning artifacts:
```bash
git add docs/sprints/sprint-N/
make db-backup
git add docs/backups/settledreach.db.backup
git commit -m "chore(meta): plan Sprint N: Theme"
git push
```
### 9. Present summary
Output:
- Sprint number, theme, and goal
@@ -201,3 +213,4 @@ Output:
- Carry-over count
- Open questions that need early resolution
- Files written
- Commit pushed to main
+5
View File
@@ -290,6 +290,11 @@ Task(
RULES (NON-NEGOTIABLE):
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
within the current working directory. Do NOT navigate to
parent or sibling directories. Do NOT follow .git pointers
to other directories.
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead. No exceptions.
+31
View File
@@ -6,6 +6,36 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Triangle activation proximity monologue lines — 4 smuggler lines (Kael Davan) and 5 detective lines (Sera Venn/Torek Lintar) that fire when observing triangle anchor NPCs post-activation (#597, D-039)
## [v0.1.23] — 2026-03-04
### Added
- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094)
- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577)
- Chunk streaming system — ChunkLoadRadius and cadence-gated load/unload around player position, v0.1 covers full district (#578, D-012)
- EngagementRecord component — per-NPC observation time, conversation count, and monologue trigger count tracked by perception/dialogue/monologue systems (#570)
- MovementHistoryBuffer resource — 3000-tick ring buffer of player positions with co-presence proximity query (#571)
- Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572)
- Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579)
- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580)
- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581)
- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573)
- Sova Transit District tile maps — 5 locations authored: The Terminal (44×28), The Last Shift (34×22), Maintenance Corridors (58×6), Gate Ground (40×34), Gate Gallery (32×10) (#582, #583)
### Fixed
- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584)
- LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585)
- Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574)
- Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines
- PROTOCOL_VERSION 17 → 18 mismatch — client rejected every server snapshot
- Debug console D-088 pause — sim now pauses while console is open, matching dialogue/settings overlay behavior
- Debug console settings toggle reads live state instead of ConfigFile, preventing checkbox divergence
### Changed
- PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant)
## [v0.1.22] — 2026-03-03
### Added
@@ -167,6 +197,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
### Changed
- Team boundary framing — replaced worktree-centric language with `$WORKTREE_TEAM` env var identity across CLAUDE.md and skills (sprint-start, sprint-plan, pr-review) to prevent agents from following `.git` pointers across boundaries
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
+14 -6
View File
@@ -27,14 +27,15 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
## Agent Instructions
### Worktree boundaries
### Team boundaries
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
- **All work must remain within the git root** unless explicitly instructed otherwise.
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
### Database
@@ -60,6 +61,13 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
### Testing preferences
- **Prefer live Gauntlet testing over mocks.** For visual tests and rendering verification, use the full client/server pipeline (`--test-mode` + `SR_LIVE=1`) instead of TestHarness mocks. The Gauntlet test world produces production-identical data. Mocks can mask rendering bugs by taking different code paths.
- **Gauntlet rooms are immutable.** Never modify existing rooms — new systems get new rooms. This ensures StableId determinism and fixture stability.
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
### 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)
+8 -2
View File
@@ -5,7 +5,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
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 \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
@@ -110,6 +110,9 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@# First import may error on theme/font loading before the import scan completes.
@# Run twice: first pass generates imports silently, second pass validates clean.
@$(GODOT) --headless --path client --import --quit 2>/dev/null || true
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -144,6 +147,9 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-gauntlet:
cd server && cargo test --test gen_gauntlet_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
@@ -347,5 +353,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot/* client/reports
rm -rf client/reports
@echo "Clean complete."
+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=28 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"]
@@ -26,6 +26,7 @@
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"]
[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -194,3 +195,6 @@ layer = 30
; #257: Loading screen — full-screen overlay during save/load round-trip
[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")]
; #581: Debug console — tilde key toggles, bottom 40% of screen
[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")]
+8
View File
@@ -161,6 +161,14 @@ func update_from_state() -> void:
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
_vis_bytes[py * _width + px] = VIS_FORWARD
# #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly,
# but NOT in visible_positions so they don't persist as explored memory.
for pos in GameState.boundary_positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
_vis_bytes[py * _width + px] = VIS_FORWARD
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
+23 -4
View File
@@ -18,7 +18,8 @@ var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
var visible_tiles: Array = []
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
# v2 fields (D-015, D-031)
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
@@ -81,6 +82,11 @@ var rng_seed: Variant = null
# One-shot: consumed by main.gd after display, then set back to null.
var save_result: Variant = null
# v18 fields (#580): debug console response from server.
# {command: String, text: String, success: bool} or null.
# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null.
var debug_response: Variant = null
# #257: Pending load path — set by main menu "Load Game" selection.
# main.gd sends LOAD_GAME on startup if non-empty, then clears this field.
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
@@ -311,6 +317,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
save_result = null
# v18: debug_response (#580) — debug console command result.
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
debug_response = snapshot.debug_response
else:
debug_response = null
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# Only update when field is present (null means no change, server sends when KG changes).
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
@@ -336,17 +348,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
# Derives visible_positions when not explicitly provided (real server mode).
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
visibility_sectors.clear()
var has_explicit_positions := snapshot.has("visible_positions")
if not has_explicit_positions:
visible_positions.clear()
boundary_positions.clear()
for vtile in snapshot.visible_tiles:
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
continue
var pos := Vector2i(vtile.x, vtile.y)
var vis_sector: String = vtile.get("visibility", "")
if vtile.has("visibility"):
visibility_sectors[pos] = vtile.visibility
if not has_explicit_positions:
visibility_sectors[pos] = vis_sector
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
# explored — they don't update the player's exploration memory when they leave LOS.
if vis_sector == "BoundaryWall":
boundary_positions[pos] = true
elif not has_explicit_positions:
visible_positions[pos] = true
+1
View File
@@ -24,6 +24,7 @@ enum Action {
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
}
var input_queue: Array[Dictionary] = []
+2
View File
@@ -416,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String:
return "SaveGame" # #554: F5 quicksave (D-085)
InputMapper.Action.LOAD_GAME:
return "LoadGame" # #554: F6 quickload (D-085)
InputMapper.Action.DEBUG_COMMAND:
return "DebugCommand" # #581: debug console command dispatch
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
+24 -2
View File
@@ -22,6 +22,7 @@ extends Node2D
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
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
@@ -122,6 +123,16 @@ func _ready() -> void:
_router.register("conversation_ended", _consume_conversation_ended)
_router.register("dialogue_response", _consume_dialogue_response)
_router.register("save_result", _consume_save_result)
_router.register("debug_response", _consume_debug_response)
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
if settings_dialog and debug_console:
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
if debug_console:
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
func _process(delta: float) -> void:
@@ -351,7 +362,8 @@ func _consume_dialogue() -> void:
dialogue_box.show_dialogue(
dlg.get("npc_name", ""),
dlg.get("speech", ""),
dlg.get("options", [])
dlg.get("options", []),
_last_dialogue_npc_id
)
GameState.current_dialogue = null
@@ -381,11 +393,13 @@ func _consume_dialogue_response() -> void:
if GameState.dialogue_response == null or not dialogue_box:
return
var dr: Dictionary = GameState.dialogue_response
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
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", ""))
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
GameState.dialogue_response = null
@@ -413,6 +427,14 @@ func _consume_save_result() -> void:
monologue_display.show_notification(msg)
# #581: Forward debug_response from server to the debug console.
func _consume_debug_response() -> void:
if GameState.debug_response == null or not debug_console:
return
debug_console.append_response(GameState.debug_response)
GameState.debug_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({
+13 -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 = 17
const PROTOCOL_VERSION: int = 18
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -244,6 +244,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"error": raw_save.get("error"),
}
# v18: debug_response (#580) — debug console command result.
# {command: String, text: String, success: bool}
var debug_response: Variant = null
var raw_debug: Variant = raw.get("debug_response")
if raw_debug is Dictionary:
debug_response = {
"command": str(raw_debug.get("command", "")),
"text": str(raw_debug.get("text", "")),
"success": bool(raw_debug.get("success", false)),
}
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
# When server populates this field, client-side accumulation fallback in game_state.gd
@@ -320,6 +331,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
"debug_response": debug_response,
"stationary_ticks": stationary_ticks,
"zone_id": zone_id,
}
+10 -17
View File
@@ -179,9 +179,7 @@ func snapshot() -> Dictionary:
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"tiles": _tiles(),
"visible_tiles": _visible_tiles(),
"visible_positions": _visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
@@ -229,7 +227,7 @@ func _visible_tiles() -> Array:
var vtiles: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
var radius := 5
var room_x := 7
var room_y := 7
var room_w := 8
@@ -241,27 +239,22 @@ func _visible_tiles() -> Array:
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
return vtiles
func _visible_positions() -> Array:
var positions: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
func _get_tile_type(x: int, y: int) -> String:
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(px - radius, px + radius + 1):
for y in range(py - radius, py + radius + 1):
var dist := absf(x - px) + absf(y - py)
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
positions.append({"x": x, "y": y})
return positions
var is_edge := (x == room_x or x == room_x + room_w - 1
or y == room_y or y == room_y + room_h - 1)
if is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
return "door"
return "wall"
return "floor"
# -- Spatial helpers -----------------------------------------------------------
+1
View File
@@ -32,6 +32,7 @@ func _ready() -> void:
# Create seamless noise texture for fog animation
var noise := FastNoiseLite.new()
noise.seed = 42
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.03
var noise_tex := NoiseTexture2D.new()
+7 -1
View File
@@ -64,11 +64,17 @@ func _setup_tileset() -> void:
tile_set = ts
# Update tiles from snapshot data
# tiles: Array of {x: int, y: int, z: int, type: String}
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
# 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.
#
# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond
# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd
# so they composite correctly under the fog shader. The fog/exploration exemption is
# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd
# (boundary_positions not visible_positions). No special handling needed in this method.
func update_tiles(tiles: Array) -> void:
if not _initialized:
return
+21 -59
View File
@@ -1,17 +1,21 @@
shader_type canvas_item;
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
// State 1: Clear (forward cone) — transparent, soft gradient edge (6-8 tile radius)
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
// State 3: Unexplored — solid near-black #12141a
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
//
// Texture pipeline (fog_state.gd): binary 0/255 at 1× tile resolution → CPU Gaussian blur
// (sigma 2.0, 6-8 tile gradient) → Image.resize 4× bilinear upscale → RGBA8 convert.
// Textures arrive here with smooth sub-tile gradients — no GPU-side blur needed.
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
uniform sampler2D visibility_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D exploration_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // D-073: hard zone boundaries
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
@@ -23,40 +27,6 @@ uniform bool debug_exploration = false; // When true, render raw exploration tex
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
float sample_visibility(vec2 uv) {
vec2 t = 2.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
for (float dx = -3.0; dx <= 3.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 8.0);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(visibility_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
// Soft gradient on exploration boundary (5x5, sigma 1.5).
// Prevents hard tile-stepped staircase at explored/unexplored edge.
float sample_exploration(vec2 uv) {
vec2 t = 1.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -2.0; dy <= 2.0; dy += 1.0) {
for (float dx = -2.0; dx <= 2.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 4.5);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(exploration_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
void fragment() {
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
@@ -67,28 +37,15 @@ void fragment() {
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
// Debug mode: render raw exploration texture (bypass fog rendering).
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
// Debug mode: render RAW exploration texture with filter_linear.
// If hardware bilinear works, should show smooth sub-tile gradients.
} else if (debug_exploration) {
float explored_dbg = texture(exploration_tex, tex_uv).r;
if (explored_dbg > 0.9) {
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
} else if (explored_dbg > 0.1) {
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
} else {
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
}
float explored_raw = texture(exploration_tex, tex_uv).r;
COLOR = vec4(vec3(explored_raw), 0.9);
} else {
float vis_raw = texture(visibility_tex, tex_uv).r;
float vis = sample_visibility(tex_uv);
float explored_raw = texture(exploration_tex, tex_uv).r;
float explored = sample_exploration(tex_uv);
// Prevent gradient bleed into never-explored tiles (use raw, unblurred value)
if (explored_raw < 0.01 && vis_raw < 0.01) {
vis = 0.0;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black — information zero
@@ -106,8 +63,13 @@ void fragment() {
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Soft edge between explored and unexplored (blurred to avoid staircase)
float exp_fade = smoothstep(0.0, 0.3, explored);
// Soft edge between explored and unexplored.
// The exploration texture is binary (explored-or-not) blurred over ~12 tiles.
// At the physical tile boundary, explored ≈ 0.5. Squaring the fade keeps
// fog nearly opaque there (97%), hiding tile-aligned content edges.
// Content appears gradually 4-6 tiles inside the explored area.
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper curve: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -167,8 +167,8 @@ func test_entity_terrain_uses_object_color() -> void:
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[70] as ColorRect
assert_that(node.color).override_failure_message(
var node = renderer.entity_nodes[70] as Sprite2D
assert_that(node.self_modulate).override_failure_message(
"Terrain kind should use ENTITY_COLOR_OBJECT"
).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
@@ -181,8 +181,8 @@ func test_entity_player_color_regardless_of_sector() -> void:
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[80] as ColorRect
assert_that(node.color).override_failure_message(
var node = renderer.entity_nodes[80] as Sprite2D
assert_that(node.self_modulate).override_failure_message(
"Player color must be constant regardless of visibility sector"
).is_equal(Constants.ENTITY_COLOR_PLAYER)
# Alpha should still be dimmed for Peripheral
@@ -0,0 +1,153 @@
## Sprint 23 #573: dialogue speaker color binding tests.
##
## Verifies entity-ID-bound speaker color assignment in dialogue_box.gd:
## round-robin palette allocation, same-entity reuse, conversation-end reset,
## and fallback behavior when no entity ID is provided.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestDialogueSpeakerColors
extends GdUnitTestSuite
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSpeakerColors: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func before_test() -> void:
GameState.dialogue_active = false
func after_test() -> void:
GameState.dialogue_active = false
# -- _assign_npc_color: round-robin assignment ---------------------------------
func test_assign_npc_color_returns_palette_color() -> void:
## First call for an entity ID should return a color from the NPC palette.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color: Color = box._assign_npc_color(100)
assert_that(color).override_failure_message(
"_assign_npc_color must return a non-default color for a valid entity ID (#573)"
).is_not_equal(box._speech_color)
func test_assign_npc_color_same_entity_returns_same_color() -> void:
## Repeated calls for the same entity ID must return the same color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color1: Color = box._assign_npc_color(200)
var color2: Color = box._assign_npc_color(200)
assert_that(color1).override_failure_message(
"_assign_npc_color must return the same color for the same entity ID (#573)"
).is_equal(color2)
func test_assign_npc_color_different_entities_get_different_colors() -> void:
## Different entity IDs should get different colors (within palette size).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color1: Color = box._assign_npc_color(300)
var color2: Color = box._assign_npc_color(301)
assert_that(color1).override_failure_message(
"Different entity IDs must get different palette colors (#573)"
).is_not_equal(color2)
func test_assign_npc_color_negative_id_returns_speech_color() -> void:
## Negative entity ID (no entity) should fall back to _speech_color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color: Color = box._assign_npc_color(-1)
assert_that(color).override_failure_message(
"_assign_npc_color(-1) must return _speech_color fallback (#573)"
).is_equal(box._speech_color)
# -- Color registry cleared on conversation end --------------------------------
func test_color_registry_cleared_on_conversation_end() -> void:
## After hide_dialogue(), the color registry must be empty so next
## conversation starts fresh (avoids palette exhaustion).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [], 400)
assert_bool(box._npc_entity_colors.has(400)).override_failure_message(
"Entity color should be registered during conversation (#573)"
).is_true()
box.hide_dialogue()
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
"_npc_entity_colors must be cleared after conversation ends (#573)"
).is_true()
assert_int(box._next_npc_color).override_failure_message(
"_next_npc_color must reset to 0 after conversation ends (#573)"
).is_equal(0)
func test_color_registry_reset_gives_fresh_assignment() -> void:
## After conversation end + new conversation, same entity ID gets a color
## (may differ from previous conversation — that's fine, per-conversation).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [], 500)
var color1: Color = box._npc_entity_colors.get(500, Color.BLACK)
box.hide_dialogue()
box.show_dialogue("NPC", "Hi again.", [], 500)
var color2: Color = box._npc_entity_colors.get(500, Color.BLACK)
# Both should be valid palette colors (not BLACK fallback)
assert_that(color1).override_failure_message(
"First conversation color must be a palette color (#573)"
).is_not_equal(Color.BLACK)
assert_that(color2).override_failure_message(
"Second conversation color must be a palette color (#573)"
).is_not_equal(Color.BLACK)
# -- show_dialogue entity ID threading ----------------------------------------
func test_show_dialogue_registers_npc_color() -> void:
## show_dialogue with a valid entity ID must register the color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("Kael", "Welcome.", [], 600)
assert_bool(box._npc_entity_colors.has(600)).override_failure_message(
"show_dialogue must register entity color when npc_entity_id provided (#573)"
).is_true()
func test_show_dialogue_without_entity_id_no_registration() -> void:
## show_dialogue without entity ID should not register any color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
"show_dialogue without entity ID must not register colors (#573)"
).is_true()
# -- append_dialogue_response defensive guard ----------------------------------
func test_append_dialogue_response_registers_color_if_missing() -> void:
## append_dialogue_response with a valid entity_id must register the color
## even if show_dialogue was not called first (defensive guard).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.append_dialogue_response("Voss", "I see.", 700)
assert_bool(box._npc_entity_colors.has(700)).override_failure_message(
"append_dialogue_response must register color for unknown entity ID (#573)"
).is_true()
@@ -0,0 +1 @@
uid://c1dnlbnxtgqqo
+73
View File
@@ -268,6 +268,79 @@ func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------
func test_boundary_wall_tiles_not_in_visible_positions() -> void:
## #585: BoundaryWall margin tiles must NOT enter visible_positions.
## They are rendered via tile_renderer (from visible_tiles) but must not
## update the player's fog exploration memory.
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false()
assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
func test_boundary_wall_tiles_in_visibility_sectors() -> void:
## BoundaryWall visibility sector is still tracked in visibility_sectors
## (for potential future use — wall coloring, etc.)
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"},
],
})
assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true()
assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall")
func test_boundary_positions_cleared_on_new_snapshot() -> void:
## BoundaryWall positions are cleared each snapshot so stale walls don't persist.
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}],
})
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true()
GameState.apply_snapshot({
"tick": 2,
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false()
assert_that(GameState.boundary_positions.size()).is_equal(0)
func test_boundary_wall_fog_vis_forward() -> void:
## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites.
## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately.
## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag.
var fog_state = _get_fog_state()
if fog_state == null:
return
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile
{"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile
],
})
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var normal_idx: int = (0 - oy) * w + (0 - ox)
var boundary_idx: int = (0 - oy) * w + (1 - ox)
assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD
assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
# -- Z-layer compliance (D-049) -----------------------------------------------
func test_fog_overlay_z_layer() -> void:
+135
View File
@@ -24,12 +24,14 @@ func before_test() -> void:
GameState.visible_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
GameState.boundary_positions.clear()
func after_test() -> void:
GameState.visible_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
GameState.boundary_positions.clear()
# -- Spec constants (D-059) ---------------------------------------------------
@@ -489,6 +491,139 @@ func test_visible_positions_cleared_on_new_snapshot() -> void:
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
func test_boundary_positions_populated_from_snapshot() -> void:
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
# Fog lifts for boundary wall tiles so wall content composites correctly.
GameState.apply_snapshot({
"tick": 20,
"visible_tiles": [
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
],
})
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
"Forward tile must be in visible_positions"
).is_true()
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
"BoundaryWall tile must NOT be in visible_positions (#585)"
).is_false()
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
"BoundaryWall tile must be in boundary_positions (#585)"
).is_true()
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
# They render through fog but are not stored as exploration memory.
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
GameState.visible_tiles = [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
]
fog_state.update_from_state()
var vis_bytes = fog_state.get("_vis_bytes")
var exp_bytes = fog_state.get("_exp_bytes")
if vis_bytes == null or exp_bytes == null:
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 6 - ox
var py := 5 - oy
if px < 0 or py < 0 or px >= w:
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
return
var idx := py * w + px
if idx < 0 or idx >= vis_bytes.size():
return
assert_int(vis_bytes[idx]).override_failure_message(
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
).is_equal(fog_state.VIS_FORWARD)
assert_int(exp_bytes[idx]).override_failure_message(
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
).is_not_equal(fog_state.EXP_VISIBLE)
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Frame 1: BoundaryWall at (6,5) is visible
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
GameState.visible_tiles = [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
]
fog_state.update_from_state()
# Frame 2: both leave LOS
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
GameState.visible_tiles = []
fog_state.update_from_state()
var exp_bytes = fog_state.get("_exp_bytes")
if exp_bytes == null:
return
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
if w <= 0:
return
var px := 6 - ox
var py := 5 - oy
if px >= 0 and py >= 0 and px < w:
var idx := py * w + px
if idx >= 0 and idx < exp_bytes.size():
assert_int(exp_bytes[idx]).override_failure_message(
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
).is_equal(fog_state.EXP_UNEXPLORED)
func test_boundary_wall_cleared_on_new_snapshot() -> void:
# #585: boundary_positions must be cleared each tick — old walls must not persist.
# BoundaryWall positions shift as the player moves; stale positions would lift fog
# where no wall exists.
GameState.apply_snapshot({
"tick": 30,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
],
})
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
GameState.apply_snapshot({
"tick": 31,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
],
})
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
).is_false()
# -- Performance (D-059) -------------------------------------------------------
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
+1
View File
@@ -0,0 +1 @@
uid://bxhgo1e4rvfmi
+23 -23
View File
@@ -218,9 +218,9 @@ func test_entity_renderer_positions_centered() -> void:
renderer.update_entities([_test_entities[0]])
var node = renderer.entity_nodes[1]
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
var expected_x: float = 5.0 * Constants.TILE_SIZE + offset
var expected_y: float = 5.0 * Constants.TILE_SIZE + offset
# Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0
var expected_x: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
var expected_y: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
assert_that(node.position.x).is_equal_approx(expected_x, 0.01)
assert_that(node.position.y).is_equal_approx(expected_y, 0.01)
renderer.queue_free()
@@ -248,9 +248,9 @@ func test_entity_renderer_player_color_differs_from_npc() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities)
var player_node = renderer.entity_nodes[1] as ColorRect
var npc_node = renderer.entity_nodes[2] as ColorRect
assert_that(player_node.color != npc_node.color).is_true()
var player_node = renderer.entity_nodes[1] as Sprite2D
var npc_node = renderer.entity_nodes[2] as Sprite2D
assert_that(player_node.self_modulate != npc_node.self_modulate).is_true()
renderer.queue_free()
func test_entity_renderer_empty_entities_clears_all() -> void:
@@ -268,23 +268,23 @@ func test_entity_renderer_empty_entities_clears_all() -> void:
func test_entity_renderer_player_uses_d033_color() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1] as ColorRect
assert_that(player_node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
var player_node = renderer.entity_nodes[1] as Sprite2D
assert_that(player_node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_entity_renderer_npc_uses_unknown_teal() -> void:
var renderer := _make_entity_renderer()
renderer.update_entities(_test_entities_v2)
var npc_node = renderer.entity_nodes[2] as ColorRect
assert_that(npc_node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
var npc_node = renderer.entity_nodes[2] as Sprite2D
assert_that(npc_node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_entity_renderer_object_uses_grey() -> void:
var renderer := _make_entity_renderer()
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
renderer.update_entities(obj)
var node = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
var node = renderer.entity_nodes[3] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
func test_entity_renderer_peripheral_entity_dimmed() -> void:
@@ -319,14 +319,14 @@ func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest (raw: 3PI/4 + PI/2 = 5PI/4)
PI: 3.0 * PI / 2.0, # West (raw: PI + PI/2 = 3PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest: wraps negative — Godot returns raw un-normalised rotation
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
@@ -405,9 +405,9 @@ func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
{"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)
# Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, 0.01)
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y, 0.01)
renderer.queue_free()
+78
View File
@@ -24,6 +24,7 @@ var _flow: String = ""
var _interval: float = 3.0
var _list_mode: bool = false
var _config: Dictionary = {}
var _is_live: bool = false
func _init():
@@ -73,6 +74,19 @@ func _run():
return
var main_node = main_scene.instantiate()
# Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim()
_is_live = OS.get_environment("SR_LIVE") == "1"
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var port_env := OS.get_environment("SR_PORT")
if port_env.is_empty():
push_error("visual_capture: SR_LIVE=1 but SR_PORT not set")
quit(1)
return
sim_bridge.server_port = int(port_env)
print("visual_capture: live mode — server port %d" % sim_bridge.server_port)
root.add_child(main_node)
# Wait for NoiseTexture2D async generation
@@ -92,6 +106,36 @@ func _run():
for i in range(settle_count):
await process_frame
# Live mode: wait for server connection and first snapshot
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var game_state := root.get_node("/root/GameState")
print("visual_capture: waiting for server connection...")
var max_frames := 300 # 5 seconds at 60fps
var waited := 0
while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED:
if sim_bridge.state == sim_bridge.ConnectionState.ERROR:
push_error("visual_capture: server connection failed")
quit(1)
return
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: connection timeout after %d frames" % waited)
quit(1)
return
print("visual_capture: connected after %d frames" % waited)
# Wait for first snapshot from server
waited = 0
while game_state.current_tick == 0:
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: no snapshot after %d frames" % waited)
quit(1)
return
print("visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited])
if not _scenario.is_empty():
await _run_scenario(main_node)
elif not _flow.is_empty():
@@ -123,6 +167,40 @@ func _run_scenario(_main_node: Node) -> void:
# Post-tick setup (e.g. zone tint patching)
_scenarios.post_setup(_scenario, root)
# Replay snapshot: inject a real server snapshot through the FULL client pipeline.
# Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd,
# then applies through GameState → FogState → shader — same path as live game.
var replay_path: String = scenario_cfg.get("replay_snapshot", "")
if not replay_path.is_empty():
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
var abs_path := repo_root.path_join(replay_path)
var rf := FileAccess.open(abs_path, FileAccess.READ)
if rf == null:
push_error("visual_capture: cannot open replay snapshot %s" % abs_path)
quit(1)
return
var replay_bytes := rf.get_buffer(rf.get_length())
rf.close()
# Decode through Protocol.decode_snapshot() — same as live IPC receive path.
# This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc.
var replay_data: Variant = Protocol.decode_snapshot(replay_bytes)
if replay_data == null or not replay_data is Dictionary:
push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path)
quit(1)
return
print("visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [
replay_path, replay_bytes.size(),
str(replay_data.get("tick", "?")),
replay_data.get("visible_tiles", []).size()])
var game_state := root.get_node("/root/GameState")
var fog_state := root.get_node("/root/FogState")
game_state.apply_snapshot(replay_data)
fog_state.update_from_state()
# Extra frames for fog uniform propagation
for i in range(4):
await process_frame
# Extra frames for state propagation + viewport texture lag
await process_frame
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://pd1qpgxiodig
+11
View File
@@ -83,6 +83,17 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
sim_bridge.harness.player_pos = Vector2i(11, 9)
sim_bridge.harness.process_input("Interact")
"fog_live_replay", "fog_theater_replay", "fog_boundary_replay":
# Replay real server snapshots via MessagePack → Protocol.decode_snapshot().
# Setup handled by visual_capture.gd (reads replay_snapshot from config).
pass
"fog_live_hub":
# Live server connection — Hub spawn position.
# No setup needed: server starts in --test-mode with Gauntlet,
# player spawns at Hub (50,58). Captures real fog pipeline output.
pass
_:
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
return false
+1
View File
@@ -0,0 +1 @@
uid://dna10a0ln5pd0
+16 -1
View File
@@ -11,6 +11,7 @@ extends Control
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
## - screenshot.png — viewport capture taken before dialog opened
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
@@ -36,6 +37,7 @@ const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
var _captured_screenshot: Image = null
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
@@ -201,6 +203,8 @@ func _get_filled_snapshot_count() -> int:
func start_capture() -> void:
if _active:
return
# Capture screenshot BEFORE showing the dialog overlay
_captured_screenshot = get_viewport().get_texture().get_image()
_active = true
visible = true
@@ -247,6 +251,8 @@ func _close() -> void:
_line_edit.queue_free()
_line_edit = null
_captured_screenshot = null
# Unpause the simulation
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
@@ -345,7 +351,16 @@ func _save_report(description: String) -> void:
else:
push_error("BugReport: failed to write %s" % seed_path)
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
# 7. screenshot.png — viewport capture taken before dialog opened
if _captured_screenshot:
var screenshot_path := base_path + "/screenshot.png"
var img_err := _captured_screenshot.save_png(screenshot_path)
if img_err == OK:
files_saved += 1
else:
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
+326
View File
@@ -0,0 +1,326 @@
class_name DebugConsole
extends Control
## In-game debug console (#581). Tilde key (`) toggles open/closed.
## Semi-transparent panel anchored to bottom ~40% of screen.
## Dispatches DebugCommandKind variants to server via SimBridge.
## Settings-toggled; enabled state persisted in user://settings.cfg.
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
signal pause_requested # D-088: pause sim while console is open
signal unpause_requested # D-088: unpause sim when console closes
const PREFS_PATH := "user://settings.cfg"
const PREFS_SECTION := "debug"
const PREFS_KEY_ENABLED := "console_enabled"
const MAX_LOG_LINES := 50
const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color("#c8d0e0")
const SUCCESS_COLOR := Color("#6bc9a6")
const ERROR_COLOR := Color("#d45d5d")
const INPUT_COLOR := Color("#e8c547")
var _enabled: bool = true
var _open: bool = false
var _log_lines: Array[String] = []
var _panel: PanelContainer = null
var _output_log: RichTextLabel = null
var _input_line: LineEdit = null
var _history: Array[String] = []
var _history_idx: int = -1
func _ready() -> void:
_load_prefs()
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT)
_build_ui()
get_viewport().size_changed.connect(_update_panel_layout)
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.anchor_left = 0.0
_panel.anchor_top = 0.6
_panel.anchor_right = 1.0
_panel.anchor_bottom = 1.0
_panel.offset_left = 0.0
_panel.offset_top = 0.0
_panel.offset_right = 0.0
_panel.offset_bottom = 0.0
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = BG_COLOR
bg_style.border_color = BORDER_COLOR
bg_style.border_width_top = 1
bg_style.content_margin_left = 8.0
bg_style.content_margin_right = 8.0
bg_style.content_margin_top = 6.0
bg_style.content_margin_bottom = 6.0
_panel.add_theme_stylebox_override("panel", bg_style)
add_child(_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4)
_panel.add_child(vbox)
_output_log = RichTextLabel.new()
_output_log.bbcode_enabled = true
_output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
_output_log.scroll_following = true
_output_log.selection_enabled = true
_output_log.add_theme_color_override("default_color", TEXT_COLOR)
_output_log.add_theme_font_size_override("normal_font_size", 13)
vbox.add_child(_output_log)
var sep := HSeparator.new()
vbox.add_child(sep)
_input_line = LineEdit.new()
_input_line.placeholder_text = "enter command (help for list)"
_input_line.clear_button_enabled = false
_input_line.add_theme_font_size_override("font_size", 13)
_input_line.add_theme_color_override("font_color", INPUT_COLOR)
_input_line.text_submitted.connect(_on_input_submitted)
_input_line.gui_input.connect(_on_input_key)
vbox.add_child(_input_line)
func _update_panel_layout() -> void:
# Anchors handle resize automatically; no manual size calc needed.
pass
# -- Input handling --
func _unhandled_input(event: InputEvent) -> void:
if not _enabled:
return
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode == KEY_QUOTELEFT:
get_viewport().set_input_as_handled()
_toggle()
return
if _open:
# Consume all keyboard events — prevent movement/action leaking through
get_viewport().set_input_as_handled()
if event.keycode == KEY_ESCAPE:
_close()
func _on_input_key(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode == KEY_UP:
_history_up()
get_viewport().set_input_as_handled()
elif event.keycode == KEY_DOWN:
_history_down()
get_viewport().set_input_as_handled()
func _toggle() -> void:
if _open:
_close()
else:
_open_console()
func _open_console() -> void:
_open = true
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
_input_line.clear()
_input_line.grab_focus()
_history_idx = -1
pause_requested.emit() # D-088: pause sim while typing debug commands
func _close() -> void:
_open = false
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
_input_line.release_focus()
unpause_requested.emit() # D-088: resume sim when console closes
func is_open() -> bool:
return _open
# -- Command input --
func _on_input_submitted(text: String) -> void:
var trimmed := text.strip_edges()
_input_line.clear()
_history_idx = -1
if trimmed.is_empty():
return
if _history.is_empty() or _history[0] != trimmed:
_history.push_front(trimmed)
if _history.size() > 20:
_history.pop_back()
_append_text("> " + trimmed, TEXT_COLOR)
_dispatch(trimmed)
func _dispatch(line: String) -> void:
var parts := line.split(" ", false)
if parts.is_empty():
return
var cmd := parts[0].to_lower()
match cmd:
"help":
_print_help()
"ticks":
if parts.size() < 2 or not parts[1].is_valid_int():
_append_text("usage: ticks <n>", ERROR_COLOR)
return
var n := int(parts[1])
if n <= 0:
_append_text("ticks: n must be > 0", ERROR_COLOR)
return
_send_debug({"AdvanceTicks": n})
"contaminate":
_send_debug("SkipToContamination")
"tp":
if parts.size() < 2:
_append_text("usage: tp <x> <y> [z] or tp <location_name>", ERROR_COLOR)
return
if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int():
var z := 0
if parts.size() >= 4:
if parts[3].is_valid_int():
z = int(parts[3])
else:
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
_send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}})
else:
var loc := " ".join(PackedStringArray(parts.slice(1)))
_send_debug({"TeleportToLocation": loc})
"activate":
_send_debug("ForceContaminationActivate")
"triangle":
if parts.size() < 2:
_append_text("usage: triangle <id>", ERROR_COLOR)
return
_send_debug({"ForceTriangleActivation": parts[1]})
"npc":
if parts.size() < 2 or not parts[1].is_valid_int():
_append_text("usage: npc <entity_id>", ERROR_COLOR)
return
_send_debug({"InspectNpc": int(parts[1])})
"triangles":
_send_debug("ListTriangles")
"pop":
_send_debug("ListPopulation")
"status":
_send_debug("GetContaminationStatus")
_:
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
func _send_debug(kind: Variant) -> void:
var err := SimBridge.send_input({
"action": InputMapper.Action.DEBUG_COMMAND,
"action_data": kind,
"timestamp_msec": Time.get_ticks_msec(),
})
if err != OK:
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
# -- Response display --
## Append a server debug response to the output log. Auto-opens console if closed
## (only if console is enabled — respect user's settings toggle).
func append_response(response: Dictionary) -> void:
var success: bool = response.get("success", false)
var text: String = response.get("text", "")
var color := SUCCESS_COLOR if success else ERROR_COLOR
_append_text(text, color)
if not _open and _enabled:
_open_console()
# -- Log rendering --
func _append_text(text: String, color: Color) -> void:
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
if _log_lines.size() > MAX_LOG_LINES:
_log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES)
if _output_log:
_output_log.text = "\n".join(_log_lines)
func _print_help() -> void:
_append_text(
"Commands:\n"
+ " ticks <n> — fast-forward N ticks\n"
+ " contaminate — skip to contamination phase\n"
+ " tp <x> <y> [z] — teleport to tile position\n"
+ " tp <location> — teleport to named location\n"
+ " activate — force contamination activate\n"
+ " triangle <id> — force triangle activation\n"
+ " npc <entity_id> — inspect NPC state\n"
+ " triangles — list all triangles\n"
+ " pop — list active NPCs\n"
+ " status — contamination status\n"
+ " help — this list",
TEXT_COLOR
)
# -- Command history --
func _history_up() -> void:
if _history.is_empty():
return
_history_idx = mini(_history_idx + 1, _history.size() - 1)
_input_line.text = _history[_history_idx]
_input_line.caret_column = _input_line.text.length()
func _history_down() -> void:
if _history_idx <= 0:
_history_idx = -1
_input_line.clear()
return
_history_idx -= 1
_input_line.text = _history[_history_idx]
_input_line.caret_column = _input_line.text.length()
# -- Settings --
func set_enabled(enabled: bool) -> void:
_enabled = enabled
if not _enabled and _open:
_close()
_save_prefs()
func is_enabled() -> bool:
return _enabled
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
_enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
cfg.load(PREFS_PATH) # load existing (may have other sections like "audio")
cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled)
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("DebugConsole: failed to save prefs (%d)" % err)
+1
View File
@@ -0,0 +1 @@
uid://c8pvt3xr7kmd2
+15
View File
@@ -0,0 +1,15 @@
[gd_scene load_steps=2 format=3 uid="uid://b2ndm9rvx8cqp"]
[ext_resource type="Script" uid="uid://c8pvt3xr7kmd2" path="res://ui/debug_console.gd" id="1_debug_console"]
; #581: In-game debug console. Tilde key toggles. ModalLayer.
; UI built programmatically in _ready() — scene contains only root node + script.
[node name="DebugConsole" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_debug_console")
+68 -13
View File
@@ -45,6 +45,15 @@ var _option_texts: Array[String] = []
var _option_is_confrontation: Array[bool] = []
var _npc_name: String = ""
# -- Entity color registry (#573) --
# Maps entity_id → Color for dialogue participants.
# Assigned from _npc_colors palette on first encounter; player uses _player_color.
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
# palette exhaustion (8 entries) across long sessions with 9+ NPCs.
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
# -- UI state --
var _active_tween: Tween = null
var _beat_tween: Tween = null # D-063: confrontation beat delay
@@ -186,16 +195,23 @@ func _update_layout() -> void:
## 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:
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
var pinned := not is_passive and _in_player_conversation
_log_entries.append({
var entry: Dictionary = {
"speaker": speaker,
"target": target,
"text": text,
"is_passive": is_passive,
"pinned": pinned,
"timestamp_msec": Time.get_ticks_msec(),
})
}
if speaker_entity_id >= 0:
entry["speaker_entity_id"] = speaker_entity_id
if target_entity_id >= 0:
entry["target_entity_id"] = target_entity_id
_log_entries.append(entry)
_log_dirty = true
_ensure_visible()
@@ -263,26 +279,34 @@ func on_conversation_ended(_event: Dictionary) -> void:
## 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_line(PLAYER_NAME, target_npc, text, false, -1, _npc_entity_id)
## 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)
## Note: expects show_dialogue() to have been called first to set _npc_entity_id.
## Defensive: if entity_id is valid but not yet registered, _assign_npc_color handles it.
func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void:
if entity_id >= 0:
_assign_npc_color(entity_id)
append_line(npc_name, PLAYER_NAME, text, false, entity_id, -1)
# -- 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_entity_id: entity ID of the NPC for stable color assignment (#573).
func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void:
_npc_name = npc_name
_npc_entity_id = npc_entity_id
_cancel_beat()
_in_player_conversation = true
if npc_entity_id >= 0:
_assign_npc_color(npc_entity_id)
# Append NPC's line to the log
if not speech.is_empty():
append_line(npc_name, PLAYER_NAME, speech, false)
append_line(npc_name, PLAYER_NAME, speech, false, npc_entity_id, -1)
# Clear old options and show new ones
_clear_options()
@@ -316,6 +340,11 @@ func _end_player_conversation() -> void:
entry.timestamp_msec = now
_log_dirty = true
# #573: Clear per-conversation color registry to avoid palette exhaustion
_npc_entity_colors.clear()
_npc_entity_id = -1
_next_npc_color = 0
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
audio_dip_cleared.emit()
@@ -509,17 +538,26 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
# 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", "?"))
# #573: use entity-ID-bound color if available; fall back to name-hash
var sp_eid: int = entry.get("speaker_entity_id", -1)
var tg_eid: int = entry.get("target_entity_id", -1)
if sp_eid >= 0 and _npc_entity_colors.has(sp_eid):
speaker_color = _npc_entity_colors[sp_eid]
else:
speaker_color = _color_for_name(entry.get("speaker", "?"))
if tg_eid >= 0 and _npc_entity_colors.has(tg_eid):
target_color = _npc_entity_colors[tg_eid]
else:
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)
# Desaturate passive name colours (Araminta review), re-enforce contrast floor after
if is_passive:
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
speaker_color = _enforce_contrast(_desaturate(speaker_color, PASSIVE_DESATURATION))
target_color = _enforce_contrast(_desaturate(target_color, PASSIVE_DESATURATION))
var sc := _color_with_alpha(speaker_color, alpha)
var ac := _color_with_alpha(_arrow_color, alpha)
@@ -546,6 +584,23 @@ static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]").replace("]", "[rb]")
## Assign a palette color to an NPC entity ID on first encounter (#573).
## Returns the same color on subsequent calls for the same entity ID.
## TODO D-033 Phase 2: derive from relationship color — current independent palette
## will need alignment when relationship-based entity colors arrive.
func _assign_npc_color(entity_id: int) -> Color:
if entity_id < 0:
return _speech_color
if _npc_entity_colors.has(entity_id):
return _npc_entity_colors[entity_id]
if _npc_colors.is_empty():
return _speech_color
var color := _enforce_contrast(_npc_colors[_next_npc_color % _npc_colors.size()])
_next_npc_color += 1
_npc_entity_colors[entity_id] = color
return color
## 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:
+30 -1
View File
@@ -11,7 +11,7 @@ const TITLE_COLOR := Color("#4a9ebb")
const FONT_SIZE := 14
const BOX_WIDTH := 460
const BOX_HEIGHT := 340
const BOX_HEIGHT := 376 # +36 for Debug Console row
const PADDING := 20
const ROW_HEIGHT := 36
@@ -28,6 +28,7 @@ var _active: bool = false
var _container: VBoxContainer = null
signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
func _ready() -> void:
@@ -109,6 +110,34 @@ func _build_ui() -> void:
db_label.text = _format_db(value)
)
# #581: Debug Console toggle
var debug_hbox := HBoxContainer.new()
debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(debug_hbox)
var debug_label := Label.new()
debug_label.text = "Debug Console"
debug_label.custom_minimum_size = Vector2(150, 0)
debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
debug_label.add_theme_font_size_override("font_size", FONT_SIZE)
debug_label.add_theme_color_override("font_color", TEXT_COLOR)
debug_hbox.add_child(debug_label)
var debug_check := CheckButton.new()
# Query live DebugConsole node if available; fall back to prefs file
var console_node := get_node_or_null("/root/Main/ModalLayer/DebugConsole")
if console_node and console_node.has_method("is_enabled"):
debug_check.button_pressed = console_node.is_enabled()
else:
var cfg := ConfigFile.new()
debug_check.button_pressed = true
if cfg.load(DebugConsole.PREFS_PATH) == OK:
debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true)
debug_check.toggled.connect(func(enabled: bool) -> void:
debug_console_toggled.emit(enabled)
)
debug_hbox.add_child(debug_check)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 8)
+1 -1
View File
@@ -70,7 +70,7 @@ func _cache_geometry() -> void:
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
var content_w := maxf(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
+7 -2
View File
@@ -75,12 +75,13 @@
"emergency",
"routine",
"observation",
"greeting"
"greeting",
"triangle_activated"
]
},
"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."
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 15 v0.1 values (triangle_activated added Sprint 24). 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",
@@ -124,6 +125,10 @@
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
},
"npc_in_los": {
"type": "boolean",
"description": "Gate: line only fires when the triggering NPC is in the player's line of sight. Added Sprint 24 for triangle_activated observe_npc lines."
}
}
}
@@ -4,10 +4,13 @@
display_name: "Sova Transit District"
description: >
A 40-year-old prefab-modular-retrofitted freight logistics hub on Station Sova.
Three social sites: The Terminal (logistics hub), The Last Shift (bar),
and maintenance corridors.
Five locations: The Terminal (logistics hub), The Last Shift (bar),
maintenance corridors, gate corridor (ground level), and the observation
gallery (Commission-only, z=2 above the gate concourse).
locations:
- "the-terminal"
- "the-last-shift"
- "maintenance-corridors"
- "gate-ground"
- "gate-gallery"
npc_count: 17
@@ -0,0 +1,48 @@
# Location: Gate Corridor — Observation Gallery
# Source: D-093 gate cluster zone spec
# Zone palette: surface #b8bec4 / fog tint #0a1222
# z=2 (above gate concourse)
canonical_id: "krenn.sova.transit.location.gate-gallery"
display_name: "Observation Gallery"
description: >
Commission-only observation gallery overlooking the gate concourse from
z=2. The gallery rail is a transparent low wall — occupants can see
down to the concourse below, but upward LOS from z=1 is blocked except
at the staircase. Access restricted to Commission personnel.
tile_bounds:
x_min: 0
y_min: 0
x_max: 31
y_max: 9
z: 2
# Legend:
# R = Restricted (Commission-only access)
# W = Wall (solid)
#
# Layout (32 wide x 10 tall):
# Row 0: North wall with staircase entrance (col 1 = R, access point)
# Rows 1-8: Gallery floor (R tiles — restricted access)
# Row 9: South wall (gallery rail — transparent low wall, modeled as W;
# cross-z LOS is handled server-side)
tiles:
- "WRWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
sightlines:
open: true
notes: >
Gallery rail is transparent — full downward LOS to gate concourse (z=1).
Upward LOS from concourse to gallery is blocked except at staircase.
Cross-z LOS behavior handled by server shadowcasting system.
@@ -0,0 +1,83 @@
# Location: Gate Corridor — Ground Level
# Source: D-093 gate cluster zone spec
# Zone palette: surface #b8bec4 / fog tint #0a1222
canonical_id: "krenn.sova.transit.location.gate-ground"
display_name: "Gate Corridor"
description: >
The gate cluster handles all traffic between Station Sova and the horizon
gate network. From north to south: the restricted aperture chamber where
span gate transits occur, freight staging and passenger arrival halls,
customs lanes (freight and pedestrian), and the wide gate concourse
where arrivals disperse into the transit district.
tile_bounds:
x_min: 0
y_min: 0
x_max: 39
y_max: 33
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# R = Restricted (access-tier gated)
#
# Layout (40 wide x 34 tall):
# Row 0: North wall
# Rows 1-4: Aperture chamber (centered 8 wide, R tiles — restricted)
# Row 5: Wall separator
# Rows 6-13: Freight staging (west, 24 tiles) | passenger arrival (east, 13 tiles)
# Row 14: Wall with door gaps (cols 12, 32)
# Rows 15-24: Freight customs (west, 20 tiles, 3 lanes at 6vt + wall stubs)
# | corridor (6 tiles) | ped customs (east, 10 tiles, 3 lanes)
# Row 25: Wall with wide opening to concourse (cols 5-34 open)
# Rows 26-33: Gate concourse (full 38-tile width, 8 rows — public open space)
#
# D-093 zone dimensions: freight customs 20x10, ped customs 12x10 (10 rows each)
# Corridor width: gate concourse 8vt (D-093)
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WWWWWWWWWWWWFWWWWWWWWWWWWWWWWWWWFWWWWWWW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WWWWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
sightlines:
open: false
notes: >
Gate concourse (rows 26-33) is wide open with full sightlines.
Customs lanes have wall stubs creating partial occlusion between
lanes. Freight customs (west, 20 tiles, 3 lanes at 6vt) is wider
than pedestrian customs (east, 10 tiles, 3 lanes). Aperture chamber
is fully walled and restricted. Cross-z LOS from the observation
gallery (z=2) into the concourse is handled server-side.
@@ -1,2 +1,54 @@
# Location: Maintenance Corridors (smuggling spaces)
# canonical_id: krenn.sova.transit.location.maintenance-corridors
# Source: D-093, #313 (Sprint 12 spatial layout)
# Zone palette: surface #4e5054 / fog tint #101214
# z=0 (Era 1, below main structures)
canonical_id: "krenn.sova.transit.location.maintenance-corridors"
display_name: "Maintenance Corridors"
description: >
Era 1 maintenance infrastructure beneath the transit district. A long
transition corridor connects the logistics hub to the bar district.
Restricted storage at the west end is shared with The Terminal above.
A maintenance hatch at the east end provides the cross-z connection
to the main level. Everything reads as mundane maintenance — the ring
operates here because nobody looks twice.
tile_bounds:
x_min: 0
y_min: 0
x_max: 57
y_max: 5
z: 0
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# R = Restricted (access-tier gated)
#
# Layout (58 wide x 6 tall):
# Row 0: North wall
# Row 1: Restricted storage (west, 8 tiles) | wall | corridor (40 tiles) | wall | hatch room (east, 6)
# Row 2: Doors connect rooms (F in wall positions at cols 9 and 50)
# Row 3-4: Same as row 1 (walls between rooms)
# Row 5: South wall
#
# Corridor width: 2vt visible at rows 1-4 (internal height, maintenance standard)
# Transition corridor: 40 tiles (~40m at 1m/vt) between storage and hatch per D-093
# Restricted storage: R tiles for access restriction
# Maintenance hatch: F tiles (cross-z connection is future movement feature)
# Design: mundane maintenance appearance per D-093 G-08
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WRRRRRRRRFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
sightlines:
open: false
notes: >
Long straight corridor (40 tiles) with limited concealment. Storage and
hatch rooms are separated by walls with single-tile doors at row 2.
Mundane appearance per D-093 G-08 — no visually suspicious tile
arrangements.
@@ -1,2 +1,71 @@
# Location: The Last Shift (bar)
# canonical_id: krenn.sova.transit.location.the-last-shift
# Source: D-093, #312 (Sprint 12 spatial layout)
# Zone palette: surface #6b4018 / fog tint #200c04
canonical_id: "krenn.sova.transit.location.the-last-shift"
display_name: "The Last Shift"
description: >
A converted maintenance bay turned bar. The long counter runs along the
west wall with full sightlines across the room. A corner booth in the
northeast sees the bar, card table, entrance, and back room — prime
observation real estate. Scattered tables fill the main floor. A back
room with an alley exit provides the key traversal route for the ring.
tile_bounds:
x_min: 0
y_min: 0
x_max: 33
y_max: 21
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
#
# Layout (34 wide x 22 tall):
# Row 0: North exterior wall
# Rows 1-4: Main floor + corner booth (NE, cols 26-31, walled alcove)
# Row 5: Bar counter starts (cols 1-2 = W fixture)
# Rows 6-10: Bar counter (cols 1-2 W), tables (W stubs), card table (col 25)
# Row 11: Open transition floor
# Rows 12-14: Table clusters (W stubs at cols 8, 16, 24)
# Row 15: News ticker mount (col 31 = W stub)
# Row 16: Open floor
# Row 17: Back room north wall with door (col 22 = F)
# Rows 18-20: Main floor (west) + back room (east, cols 22-32)
# Row 21: South wall with alley exit gap (cols 29-32 = F)
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFW"
- "WWWFFFFFFFWFFFFFFFWFFFFFFWFFFFFFFW"
- "WWWFFFFFFFWFFFFFFFWFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFWFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWFFFFW"
sightlines:
open: false
notes: >
Corner booth (NE alcove, rows 1-4) has LOS to bar counter, card table
area, main entrance, and back room door. Bar counter along west wall
(cols 1-2, rows 5-10) gives staff full sightlines across the main
floor. Back room is partially occluded by wall at col 21. Back room
door at col 22. Table W-stubs create partial cover but do not fully
block sightlines.
social_site: "bar"
@@ -1,2 +1,77 @@
# Location: The Terminal (logistics hub)
# canonical_id: krenn.sova.transit.location.the-terminal
# Source: D-093, #311 (Sprint 12 spatial layout)
# Zone palette: surface #7a8490 / fog tint #0d1520
canonical_id: "krenn.sova.transit.location.the-terminal"
display_name: "The Terminal"
description: >
Sova's freight logistics hub. Scanner bays filter incoming cargo at the
south entrance; a wide main corridor runs north through the manifest
processing floor. The break room sits in the northeast. The supervisor's
office overlooks the corridor through a large interior window. Restricted
storage in the northwest corner is where the ring hides re-tagged cargo.
tile_bounds:
x_min: 0
y_min: 0
x_max: 43
y_max: 27
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# V = Void (outside building) R = Restricted (access-tier gated)
#
# Layout (44 wide x 28 tall):
# Rows 0: North exterior wall
# Rows 1-4: Restricted storage (NW, 11 tiles) + corridor + supervisor office (NE, 9 tiles)
# Row 5: Storage south wall; corridor + office continue
# Rows 6-8: Open corridor; supervisor office east side
# Row 7: ** Supervisor window — col 28 is F (LOS gap into office) **
# Row 9: Supervisor office south wall
# Rows 10-11: Main corridor (full width chokepoint — high sightline value)
# Rows 12-17: Manifest processing (west, 14 tiles) + break room (east, 13 tiles)
# Row 18: Break room south wall; manifest open to corridor
# Rows 19-21: South corridor
# Row 22: Scanner bay north walls with entry gaps
# Rows 23-26: Scanner bay interiors (3 bays)
# Row 27: South exterior wall
tiles:
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWWWWWWWWWWWWWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWWWWWWWWWWWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFWWWWWWWWWWWWWWWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWWWWFFFFFFWWWWFFFFFFFFWWWWFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
sightlines:
open: false
notes: >
Supervisor office window faces main corridor at row 7, col 28 (F gap in
wall line). Player standing in the corridor has direct LOS into the
office interior. This is the primary investigative discovery moment.
social_site: "logistics-hub"
@@ -446,3 +446,77 @@ lines:
state: person_of_interest
priority: 8
tags: [npc, kael, investigation, evidence, analytical]
# --- Triangle Activation: Sera Venn / Torek Lintar (T2 informant-question) ---
# Fires post-TriangleActivated when player has LOS to Sera or Torek. One beat per line.
# Beat 1: pattern recognition. Beat 2: deviation logged. Beat 3: hypothesis. Beat 4: inference. Beat 5: procedural next step.
- id: pc-detective_m_d_041
text: "Venn changed her transit route. Bay six to bay four bypass — three times today."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_042
text: "She passed Torek without acknowledgment. Third instance logged. They have worked adjacent bays for months."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, torek, behavioral]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_043
text: "If she knows about the manifest discrepancy, why has she not reported it?"
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_044
text: "She is protecting something. Or someone. The distinction matters."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_045
text: "Worth a direct conversation. Controlled setting. Not here."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, procedural, friend-arc]
prerequisites:
npc_in_los: true
@@ -373,3 +373,77 @@ lines:
state: person_of_interest
priority: 9
tags: [npc, kael, contaminated-trust, friend-arc]
# --- Triangle Activation: Kael Davan (T1 hub-power) ---
# Fires post-TriangleActivated when player has LOS to Kael. One beat per line.
# Beat 1: physical observation. Beat 2: rationalization. Beat 3: doubt. Beat 4: doubt breaks.
- id: pc-smuggler_m_s_036
text: "Kael's in the main corridor. He doesn't usually come through here."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_037
text: "Voss probably moved him. Schedule shifts happen."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_038
text: "...Kael would've told me."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_039
text: "He's not looking at the cargo. He's watching the exits."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, tell, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_040
text: "I don't know what I'm looking at. But I know Kael. And this isn't Kael."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, contradiction, friend-arc]
prerequisites:
npc_in_los: true
+37 -31
View File
@@ -10,16 +10,23 @@ The fog system was simplified from the original 5-layer spec in Sprint 22 (#569)
| Original D-059 Layer | Sprint 22 Status | Notes |
|----------------------|------------------|-------|
| 1. Clear (forward cone) | Implemented | Soft Gaussian gradient (7x7, sigma 2.0) |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); Gaussian blur provides soft transition |
| 3. Deep fog (previously explored) | Implemented — EXP_EXPLORED | Alpha 0.55-0.70 with zone temperature tint (#563) |
| 1. Clear (forward cone) | Implemented | CPU Gaussian blur (sigma 2.0) → 4× bilinear upscale → RGBA8. Smooth sub-tile gradients. |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); CPU blur + bilinear upscale provides soft transition |
| 3. Deep fog (previously explored) | Simplified — D-015 light fog | Alpha 0.25-0.35 with zone temperature tint. Explored tiles show through light haze. |
| 4. Unexplored + maps app | Deferred | v0.1.2+, requires mapped_tiles in ObserverSnapshot |
| 5. Unexplored (no maps) | Implemented | Solid near-black #12141a |
**Alpha values (Sprint 22, #563):**
- Light fog zone (near cone gradient, vis 0.0-0.3): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.55-0.70, breathing ±0.075 (15-20s)
- Zone temperature tint active in deep fog zone (D-059/D-046/D-077)
**Texture pipeline (fog_state.gd):**
Binary 0/255 data at 1× tile resolution → CPU Gaussian blur (sigma 2.0, radius 4)
→ `Image.resize()` 4× bilinear upscale → `Image.convert()` RGBA8.
GL compat mode doesn't bilinear-filter R8 textures; RGBA8 at 4× resolves this.
Exploration data is binarized (0/128/255 → 0/255) before blur to avoid a
second gradient at the explored/visible boundary.
**Alpha values (Sprint 22, D-015):**
- Light fog zone (explored, out of cone): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Zone temperature tint active in explored zone (D-059/D-046/D-077)
- Explored/unexplored boundary: squared fade keeps fog opaque at tile content edge
## Overview
@@ -120,30 +127,27 @@ func update_from_state() -> void:
File: `client/shaders/fog.gdshader`
The shader determines fog state per pixel based on the visibility and exploration textures.
Two explored sub-zones are distinguished by the blurred `vis` value (proximity to the forward cone):
The shader reads pre-smoothed RGBA8 textures (CPU blur + 4× bilinear upscale)
and determines fog state per pixel. No GPU-side blur — 2 texture reads per pixel.
```glsl
// Light fog noise — fast cycle (8-10s), subtle ±0.05 breathing
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// Deep fog noise — slow cycle (15-20s), more pronounced ±0.075 breathing
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05; // 0.25-0.35
// Light fog: alpha 0.25-0.35 (near cone gradient)
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
// Deep fog: alpha 0.55-0.70 (far from cone, EXP_EXPLORED)
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
// Blend deep <-> light fog by proximity to cone:
// vis=0 (far from cone) → deep_factor=1 → deep fog color + alpha
// vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
// vis=0.85+ (inside cone) → clarity=1 → transparent (clear)
float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);
// Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
// Clarity ramp: transparent inside cone, light fog at edges and beyond
float clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Explored/unexplored boundary: squared fade hides tile content edges
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
```
**Zone temperature palette (D-046):**
@@ -215,11 +219,13 @@ Per tick (in _process or on snapshot signal):
| Component | Budget | Estimate | Notes |
|-----------|--------|----------|-------|
| Visibility texture upload | 0.1ms | ~0.05ms | 400 pixels via set_pixel() |
| Exploration texture update | 0.1ms | ~0.05ms | Incremental — only changed tiles |
| Fragment shader (1080p) | 0.5ms | ~0.2ms | Single full-screen pass, simple math |
| CPU Gaussian blur (1×, 40×40) | 0.2ms | ~0.1ms | Separable, sigma=2.0, radius=4 (vis: 1 pass, exp: 2 passes) |
| Image.resize C++ (40→160) | 0.1ms | ~0.05ms | INTERPOLATE_BILINEAR, 2× textures |
| Image.convert R8→RGBA8 | 0.1ms | ~0.02ms | GL compat bilinear requires RGBA8 |
| Texture upload (RGBA8 160×160) | 0.2ms | ~0.1ms | 2× textures, ~200KB total |
| Fragment shader (1080p) | 0.2ms | ~0.05ms | 2 texture reads/px (was 98 with GPU blur) |
| Fog entity sprites | 0.2ms | ~0.05ms | 0-15 sprites, trivial draw calls |
| **Total** | **<1ms** | **~0.35ms** | Well within D-059 budget |
| **Total** | **<1ms** | **~0.37ms** | Well within D-059 budget |
## Files to Create
Binary file not shown.
+116
View File
@@ -0,0 +1,116 @@
# Sprint 23: Terrain — Client Tasks
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #573 | Dialogue speaker colors are position-based instead of speaker-bound | — |
| #574 | Pre-existing test failures in test_entity_renderer (test_rendering.gd) | — |
| #581 | Debug console client: tilde console UI, settings toggle, command dispatch | #580 (server) |
| #585 | Walls at LOS boundary: client — render margin tiles correctly through fog | #584 (server) |
Use `tooling/db/ticket show <id>` for full descriptions.
---
## Key Decisions
- `decisions/architecture.md` — D-020 (ObserverSnapshot is the only data crossing IPC boundary), D-088 (3-state pause — Normal/Overlay/Paused)
- `decisions/content.md` — D-033 (entity color = relationship to player; player color set at character creation), D-076 (dialogue box max-width 1200px)
- `decisions/perception.md` — D-059 (fog system — 3-state: visible, explored, never-seen)
---
## Notes
### #573 — Dialogue speaker colors
**What exists:** `client/ui/dialogue_box.gd` renders the dialogue box (D-028, D-076). Speaker colors are currently assigned by position (left speaker = color A, right speaker = color B), which means all left-side speakers share one color and all right-side speakers share another regardless of identity.
**What to deliver:** Bind speaker color to entity identity, not screen position. The `ObserverSnapshot` carries entity IDs on dialogue lines (check `client/scripts/protocol/protocol.gd` `_decode_nearby_interaction` for the entity_id field on dialogue events). Maintain a `Dict[entity_id -> Color]` in `dialogue_box.gd`. On first encounter with an entity ID, assign a color from a palette and store it. Subsequent lines from the same entity reuse the stored color. The palette should be distinct from D-033 relationship colors (those are world-layer entity colors; dialogue colors are UI-layer only).
**Non-obvious gotcha:** The player character is always one speaker. Assign the player a fixed color (e.g. the character's D-033 color, or a designated white/near-white). NPCs get assigned from the palette on first appearance in the conversation.
### #574 — Entity renderer test failures
**What exists:** `client/tests/test_rendering.gd` contains `test_entity_renderer_*` tests that fail on clean main (5aea6e2). Affected: positions_centered, player_color_differs_from_npc, player_uses_d033_color, npc_uses_unknown_teal, object_uses_grey, facing_indicator_rotation_accuracy, regression_345_entity_position_set_from_entity_id_entity.
**What to deliver:** Fix the failing tests. `client/scripts/rendering/entity_renderer.gd` likely changed without the tests being updated. Read the renderer to understand the current implementation, then update tests to match (or fix the renderer if it regressed from D-033 spec). Do not change the passing visual golden tests — only fix the unit tests in `test_rendering.gd`. Run `make test-client` to verify.
**Gotcha:** D-033 specifies entity color = relationship to player. If entity_renderer.gd was updated correctly but tests use stale expected values, update the test assertions. If entity_renderer.gd was updated incorrectly and broke D-033 compliance, fix the renderer.
### #581 — Debug console client
**What exists:** `client/ui/settings_dialog.gd` — existing settings panel with 5-bus audio volume sliders. `client/ui/debug_overlay.gd` — existing debug overlay tied to `OS.is_debug_build()` and a `debug_overlay` input action. `client/scripts/main.gd` — input routing. `client/scripts/protocol/protocol.gd` — `encode_player_input(tick, action_name, action_data)` and `decode_snapshot()`.
**What to deliver:**
1. `client/ui/debug_console.gd` + `debug_console.tscn` — semi-transparent panel (40% screen height, anchored bottom). Input line at bottom, scrollable output log above (50-line buffer). Tilde key (`KEY_QUOTELEFT`) toggles open/closed. When open, swallows all keyboard input except tilde (no movement commands leak).
2. Settings integration — add a "Debug Console" checkbox to `settings_dialog.gd`. Persisted in `user://settings.cfg` alongside audio volumes. Default: enabled. When disabled, tilde key is inert.
3. Command parsing and dispatch — parse input as `<command> [args...]`. Map to `DebugCommandKind` variants. Serialize via `Protocol.encode_player_input('debug_command', { kind: ..., args: ... })`. Commands: `ticks <n>`, `contaminate`, `tp <x> <y> [z]`, `tp <name>`, `activate`, `triangle <id>`, `npc <id>`, `triangles`, `pop`, `status`, `help`.
4. Response display — read `debug_response` from the decoded snapshot dict (new optional field from server #580). Format and append to output log. Complex payloads (NPC state, triangle list) displayed as formatted key-value blocks.
5. Protocol extension — update `protocol.gd` to encode `debug_command` action and decode `debug_response` from snapshot. Bump `Protocol.PROTOCOL_VERSION` to 18 to match server.
**Coordination required:** Stig must coordinate with Dudley on the PROTOCOL_VERSION bump (#580 server) before either PR is merged. Client and server PRs must land together or within the same merge window — a version mismatch will crash the connection.
**Blocked by:** #580 (server must define `DebugCommandKind` wire format before client serialization is finalized).
### #585 — LOS boundary wall rendering
**What exists:** `client/scripts/rendering/tile_renderer.gd` — `update_tiles()` processes `VisibleTile` entries from the snapshot. The tile accumulation fix (removed `clear()` from `update_tiles()`) means explored tiles stay rendered. `client/scripts/autoloads/fog_state.gd` — manages exploration state per tile.
**What to deliver:** Handle the new `VisibilitySector::BoundaryWall` tiles from server #584. In `update_tiles()`, detect boundary wall sector tiles and:
- Render them to the TileMapLayer as wall tiles (so fog has content to composite over)
- Do NOT record them as explored in `fog_state.gd` — they are "seen right now" only, not explored
- Do NOT update the fog exploration texture for these tiles
The visual result: walls at the LOS edge are distinct from fog (they render as wall tiles) but disappear from the map when not in the current LOS cone (they don't leave a memory trace).
**Blocked by:** #584 (server must send the boundary tiles before client can render them).
---
## Dependency Chain
```
#573 (speaker color fix) — standalone
#574 (renderer test fix) — standalone
#580 (server debug) → #581 (debug console)
#584 (server wall margin) → #585 (client wall margin)
```
#573 and #574 are fully independent — start immediately. #581 is blocked on server work landing first. #585 is blocked on server #584 landing first.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "fix(client): bind dialogue speaker colors to entity identity" \
--description "body" --base main --head client
```
---
## Sprint Completion (Client Criteria)
1. Dialogue conversations show consistent per-speaker colors regardless of screen position.
2. All `test_entity_renderer_*` tests pass in `test_rendering.gd`.
3. Tilde key opens debug console in-game; `status` command returns contamination state; `triangles` lists all active triangles.
4. Debug console toggle visible in Settings menu; persists across restarts.
5. Walls at LOS edge render as distinct tile content rather than blending into fog.
6. `make test-client` green on client branch.
+102
View File
@@ -0,0 +1,102 @@
# Sprint 23: Terrain — Joint / Integration
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
**Agents:** All implementation agents (Dudley, Tyre, Hoshe, Stig, Araminta)
---
## Pre-Sprint Actions
These must happen at sprint start, before implementation tickets begin:
| Action | Owner | Blocks |
|--------|-------|--------|
| Confirm tile format (character codes, row encoding, multi-z approach) | Dudley + Araminta sync (day 1–2) | #582, #583 — Araminta cannot start until format is published in types.rs |
| Confirm DebugCommandKind wire format (exact JSON/msgpack structure) | Dudley + Stig sync (day 1–2) | #581 — client dispatch serialization depends on this |
| Confirm PROTOCOL_VERSION bump: 17 → 18 | Dudley + Stig | #580 server + #581 client must land together |
---
## Cross-Team Dependencies
| Dependency | Direction | Notes |
|------------|-----------|-------|
| Tile format spec | Server (#577) → Visual (#582, #583) | Dudley publishes format in types.rs; Araminta starts authoring same day |
| DebugCommandKind wire format | Server (#580) → Client (#581) | Stig cannot finalize protocol.gd encoding until Dudley publishes the enum |
| PROTOCOL_VERSION 17→18 | Server (#580) ↔ Client (#581) | Both must land in the same merge window; version mismatch crashes the connection |
| LOS wall margin | Server (#584) → Client (#585) | Client rendering change depends on server sending boundary tiles |
---
## Housekeeping Done This Sprint
The following stale tickets were closed during sprint planning:
| # | Reason |
|---|--------|
| #184 | Logistics hub template YAML was already complete in `templates/logistics-hub.yaml` |
| #339 | Protocol version handshake already implemented in Sprint 19 via #555/#556 |
| #143 | Superseded by richer #576 (tile-type layer with full spec) |
| #144 | Superseded by #577 (location loader is the v0.1 equivalent) |
| #145 | Superseded by richer #578 (chunk streaming with v0.1 full-preload mode) |
| #154 | Functional cluster spatial specs live in D-025 and D-093 — already done |
| #155 | Hand-crafted authoring is now #582/#583 with correct format and blocker |
| #157 | Gate corridor is covered by #583 (Last Shift + maintenance + gate) |
---
## Sprint Completion Proof
The sprint is done when all of the following are observable:
1. **Walkable world boots.** Start the server in production mode (no gauntlet feature). It loads tile data from all four Sova locations (The Terminal, The Last Shift, maintenance corridors, gate corridor). No panic. WalkabilityMap populated from YAML — walls block movement, floors allow it.
2. **Storyteller activates from player behavior.** Run a test: spawn at The Terminal, observe Kael for 3000 ticks. EngagementRecord increments. After CONTAMINATION_DELAY_TICKS, activation_pass() fires and emits TriangleActivated for the hub-power or worried-partner triangle.
3. **Debug console works end-to-end.** Boot production game. Tilde key opens console. Type `status` — returns contamination state and current tick. Type `contaminate` — skips to contamination. Type `triangles` — lists all TriangleState entities with their IDs and classifications.
4. **Debug console in settings.** Open Settings menu. Debug Console toggle is visible. Disable it. Tilde key no longer opens console.
5. **Speaker colors are identity-bound.** Open a conversation with Kael, then end it. Open another conversation with Voss. Kael's color is consistent; Voss has a different color; neither color is position-based.
6. **LOS boundary walls render.** Walk up to a wall at the edge of vision. The wall tile is visible and distinct from fog rather than bleeding into the fog overlay.
7. **CI green.** `make ci` passes on all branches. No regressions.
---
## Test Plan Alignment (D-030)
Sprint 23 is Phase 2 integration territory (D-030 Phase 2: integration tests + information boundary tests):
- **#576:** Unit tests — TileKind round-trip in WalkabilityMap. Phase 1 (data structure).
- **#577:** Integration test — production boot loads tile data, WalkabilityMap is populated correctly. Phase 2.
- **#578:** Unit test — chunk load/unload at player boundary. Phase 1.
- **#570–#572–#579:** Integration test — 3000-tick simulation asserts EngagementRecord increments + TriangleActivated fires. Phase 2.
- **#580/#581:** Manual acceptance — debug console smoke test (status, contaminate, triangles). Phase 2.
- **#584/#585:** Visual regression test — add golden test for LOS boundary wall rendering. Phase 2.
---
## Open Questions
| ID | Question | Blocks | Action |
|----|----------|--------|--------|
| Q-052 | Storyteller hint delivery channels | #579 downstream consumers | Read for context but does not block Sprint 23. #579 emits TriangleActivated; consumers wired in Sprint 24. |
---
## PR Merge Order
To avoid conflicts on shared files (`bridge/types.rs`, `protocol.gd`, `PROTOCOL_VERSION`):
1. Server PRs (#576, #570, #571, #572) — no client/visual dependencies; merge first.
2. Server PR (#577 tile format) — merge and notify Araminta immediately so visual authoring starts.
3. Server PRs (#578, #579, #584) — merge in any order after their respective blockers.
4. **Server PR #580 (debug server) + Client PR #581 (debug client)** — must land together. Coordinate merge timing. Both bump PROTOCOL_VERSION to 18.
5. Visual PRs (#582, #583) — merge after server #577 is in main.
6. Client PRs (#573, #574) — no dependencies; merge anytime.
7. Client PR #585 (LOS wall margin) — merge after server #584 is in main.
**Critical:** #580 and #581 share the PROTOCOL_VERSION bump. Do not merge one without the other.
+144
View File
@@ -0,0 +1,144 @@
# Sprint 23: Terrain — Server Tasks
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #576 | Chunk data structure: tile-type layer and ChunkStore resource | — |
| #577 | Location YAML tile format and content loader integration | #576 |
| #578 | Chunk loading/unloading: streaming around player position | #576 |
| #570 | EngagementRecord component — track per-NPC engagement metrics | — |
| #571 | MovementHistoryBuffer resource — player path history | — |
| #572 | Storyteller lifecycle rules — multi-activation, cooldown, resolution | — |
| #579 | Storyteller activation pass — implement activation_pass() system | #570, #571, #572 |
| #580 | Debug console server: command handler and state manipulation API | — |
| #584 | Walls at LOS boundary: server — extend visible_tiles with wall margin | — |
Use `tooling/db/ticket show <id>` for full descriptions.
---
## Key Decisions
- `decisions/architecture.md` — D-012 (tile/chunk spec), D-094 (chunk 32×32, block 128×128, district 256×256), D-110 (signed z-levels: base_z i8, z_levels u8)
- `decisions/content.md` — D-023 (three-tier content model, storyteller activation), D-025 (functional cluster, 15-40 tile social sites), D-093 (Sova Transit District — full spatial spec with tile dimensions), D-029 (population entanglement ratio 30/50/20)
- `decisions/architecture.md` — D-010 (determinism — BTreeMap, no float non-determinism), D-031 (10 ticks/game-minute, TICKS_PER_GAME_MINUTE)
---
## Notes
### #576 — Chunk data structure: tile-type layer
**What exists:** `WalkabilityMap` in `server/src/simulation/movement.rs` stores per-tile walkability as `Vec<bool>` inside private `ChunkData` structs, indexed by `ChunkCoord { cx, cy, z }`. `CHUNK_SIZE = 32`. The public API is `is_walkable(pos)` / `set_walkable(pos, bool)`. Generator types (`GeneratorChunkData`, `TileId`, etc.) are stubs in `server/src/simulation/generator.rs`.
**What to deliver:** Extend `ChunkData` to carry a `TileKind` per tile. Define `TileKind` enum: `Floor | Wall | Void | Restricted`. Add `tile_kind(pos) -> TileKind` and `set_tile_kind(pos, kind)` to `WalkabilityMap`. Keep `is_walkable` / `set_walkable` API intact — many existing tests use it directly. All existing tests must pass.
**Non-obvious gotcha:** `ChunkData` is private. Only `WalkabilityMap` methods are the extension surface. Do not make `ChunkData` or `TileKind` pub unnecessarily — encapsulate through the map API.
### #577 — Location YAML tile format and loader
**What exists:** `Location` struct in `server/src/content/types.rs` has `tile_bounds: Option<TileBounds>` (bounding box only) and `sightlines`. The three location YAMLs (`the-terminal.yaml`, `the-last-shift.yaml`, `maintenance-corridors.yaml`) are 2-line stubs — header comments only. `load_district()` in `server/src/content/loader.rs` already calls `load_yaml_dir::<Location>()` on the locations directory; stubs are silently skipped.
**What to deliver:** Two things. First, agree the tile format with Araminta (visual team) on day 1–2 of the sprint — she cannot start authoring until you publish the format. Suggested format: `tiles: ["FFFFF", "FWWWF", "FFFFF"]` — array of strings, one char per tile, left-to-right = +x, top-to-bottom = +y. Characters: `F`=floor, `W`=wall, `V`=void, `R`=restricted. Post the agreed format as a comment block in `types.rs` before Araminta starts.
Second, implement `load_location_tiles(location: &Location, origin: TilePosition, map: &mut WalkabilityMap)` and call it from the production startup path in `server/src/main.rs` after NPCs/triangles are spawned. The Gauntlet startup path (`server/src/test_world/`) must be untouched.
**Dependency:** #576 must be merged first (needs `set_tile_kind` API). Unblocks #582 and #583 (Araminta's tile authoring).
### #578 — Chunk loading/unloading
**What exists:** `WalkabilityMap` is pre-allocated at startup via `WalkabilityMap::new(width, height, z_levels)` which eagerly creates all chunks. `ZLevelLoadState` enum is defined in `server/src/simulation/generator.rs` with `Loaded(GeneratorChunkData) | Skeleton(FloorZone) | Ungenerated` variants but not yet used at runtime.
**What to deliver:** A chunk streaming system. For v0.1 the entire hand-authored district fits in memory (256×256 visual = 8×8 chunks of 32 tiles). Pre-load all chunks at startup; the streaming system exists but covers the full map. Architecture must support future per-demand loading (v0.3+ generator). Add `ChunkLoadRadius` resource. Add a system that tracks player position → loaded chunk set → unloads distant chunks. Test: verify chunks at boundary are unloaded when player moves away.
**Blocked by:** #576.
### #570 — EngagementRecord component
**What exists:** Nothing. The KnowledgeGraph (`server/src/knowledge/`) tracks NPC information inventory but not engagement metrics. The storyteller activation spec in #162 requires three fields per observed entity: `observation_time_ticks`, `conversation_count`, `monologue_trigger_count`.
**What to deliver:** `EngagementRecord` as a bevy ECS `Component` on NPC entities. Three fields. The perception system (`server/src/perception/observation.rs`) should increment `observation_time_ticks` per tick the NPC is in the player's LOS. The dialogue system (`server/src/simulation/dialogue.rs`) should increment `conversation_count` on conversation start. The monologue system (`server/src/simulation/monologue.rs`) should increment `monologue_trigger_count` when a monologue fires for a specific NPC context. All three writes must be additive — no overwrite.
### #571 — MovementHistoryBuffer resource
**What exists:** Nothing. `TilePosition` tracks current position. No history buffer exists.
**What to deliver:** `MovementHistoryBuffer` as a bevy `Resource`. Ring buffer of the player's `TilePosition` over the last `ENGAGEMENT_WINDOW_TICKS = 3000` ticks. The movement system (`server/src/simulation/movement.rs`) appends to it each tick when the player moves. Expose a query method: `npcs_copresent_in_window(threshold: i32) -> Vec<Entity>` — returns NPC entities whose `TilePosition` was within `threshold` tiles of any player position in the buffer. Used by the activation pass (#579).
### #572 — Storyteller lifecycle rules
**What exists:** The contamination layer fires once and sets `ContaminationActive`. No activation lifecycle spec exists.
**What to deliver:** A decision/spec (can be a code comment block or a D-record filed with Qatux, not necessarily full implementation). For v0.1: single activation per session only. No concurrent activations. No cooldown needed (one and done). Triangle resolution: when a triangle reaches a terminal phase, mark it resolved; do not re-activate. Document these rules as constants and a comment in `server/src/storyteller/mod.rs`. These rules gate #579.
### #579 — Storyteller activation pass
**What exists:** `server/src/storyteller/mod.rs` has `ContaminationActive`, `ContaminationEventQueue`, and `tick_contamination_activation()`. No activation pass, no engagement scoring, no `TriangleActivated` event.
**What to deliver:** `activation_pass()` system implementing the 6 steps from #162. Gate → proximity query → engagement scoring → routing → module selection → emit `TriangleActivated { triangle_id }`. Add `TriangleActivated` event type and `TriangleActivatedQueue` resource. Register `activation_pass` in `StorytellerPlugin.build()` — runs after contamination is active, on 10-tick cadence.
**Blocked by:** #570, #571, #572.
### #580 — Debug console server
**What exists:** `PlayerAction` enum in `server/src/bridge/types.rs` has movement, interaction, save/load, pause, `TeleportToHub`. `PROTOCOL_VERSION = 17`.
**What to deliver:** New `PlayerAction::DebugCommand(DebugCommandKind)` variant. `DebugCommandKind` enum with 9 variants (see ticket description). `DebugResponsePayload` added as `Option<>` to `ObserverSnapshot`. Bump `PROTOCOL_VERSION` to 18 when adding the new variant. A `handle_debug_commands` system that only executes when a `DebugEnabled` resource is true (set at startup; default true for v0.1). The system routes each `DebugCommandKind` to the appropriate ECS query or mutation.
**Gotcha:** `PROTOCOL_VERSION` is checked by the client on every snapshot. Dudley must coordinate with Stig on the version bump — client #581 must update `protocol.gd` simultaneously to avoid mismatch errors.
### #584 — LOS boundary wall margin
**What exists:** Observer snapshot generation is in `server/src/perception/observer/mod.rs`. `VisibilitySector` enum in `bridge/types.rs` has existing variants. `WalkabilityMap` is available via `Res<WalkabilityMap>`.
**What to deliver:** After the standard visible tiles are computed, walk the LOS boundary and add wall tiles 1 tile beyond the cone. Use a new `VisibilitySector::BoundaryWall` variant (or reuse `LosObstructed` if that field already serves this purpose — check #514). Wall margin tiles must not affect the exploration/memory state — they are "seen now" only.
---
## Dependency Chain
```
#576 (tile-type layer)
→ #577 (tile format + loader) → #582 (Terminal tile map, visual)
| → #583 (Last Shift + corridor maps, visual)
→ #578 (chunk streaming)
#570 (EngagementRecord) ─┐
#571 (MovementHistory) ─┤→ #579 (activation pass)
#572 (lifecycle rules) ─┘
#580 (debug server) → #581 (debug client)
#584 (LOS wall margin server) → #585 (LOS wall margin client)
```
Parallel tracks: spatial chain (#576→#577→#578), storyteller chain (#570-572→#579), debug chain (#580), LOS fix (#584). All four tracks are independent and can run in parallel.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(server): chunk tile-type layer and location loader" \
--description "body" --base main --head server
```
---
## Sprint Completion (Server Criteria)
1. `cargo test` green on server branch — all existing tests pass, new tile-kind tests pass.
2. Production startup (no gauntlet feature) boots with tile data loaded — WalkabilityMap populated from location YAML for The Terminal, The Last Shift, maintenance corridors.
3. `EngagementRecord` increments correctly — perception/dialogue/monologue all write to it.
4. `activation_pass()` fires after contamination and emits `TriangleActivated` for the highest-engagement NPC's triangle.
5. `DebugCommand::SkipToContamination` advances simulation to `CONTAMINATION_DELAY_TICKS` and the response is returned in the snapshot.
6. LOS wall margin — snapshot includes 1-tile wall data beyond the LOS cone boundary.
+144
View File
@@ -0,0 +1,144 @@
# Sprint 23: Terrain — Visual Tasks
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
**Branch:** `visual`
**Agents:** Araminta (art direction, tile authoring)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #582 | Author tile map: The Terminal (logistics hub) | #577 (server tile format) |
| #583 | Author tile maps: The Last Shift, maintenance corridors, and gate corridor | #577 (server tile format) |
Use `tooling/db/ticket show <id>` for full descriptions.
---
## Key Decisions
- `decisions/content.md` — D-093 (Sova Transit District — **full spatial spec with tile dimensions, zone palette, corridor widths, zone-by-zone breakdown**)
- `decisions/content.md` — D-025 (functional cluster: 15-40 tile connected spaces, internal sightlines, public/private zones)
- `decisions/architecture.md` — D-094 (spatial hierarchy: chunk 32×32 sim tiles = 16×16 visual tiles at 2x retina, D-066), D-066 (dual-scale grid: 0.5m sim, 1m visual)
- `decisions/architecture.md` — D-110 (z-levels: z=0 maintenance corridors, z=1 main structures, z=2 observation gallery)
---
## Open Questions to Resolve Early
- **Tile format (day 1–2):** Server ticket #577 must publish the tile format before authoring begins. Coordinate with Dudley. The format is expected to be: `tiles:` array of strings, one char per tile. Do not author tile data until the format is confirmed and documented in `server/src/content/types.rs`.
---
## Notes
### Tile format primer
The server will confirm a format like:
```yaml
tiles:
- "WWWWWWWWWW"
- "WFFFFFFFFFFW"
- "WFFFFFFFFFFW"
- "WWWWWWWWWW"
```
Characters: `F`=floor (walkable), `W`=wall (blocks movement/LOS), `V`=void (empty space outside the location boundary), `R`=restricted (walkable but access-tier restricted). Left-to-right = +x, top-to-bottom = +y. Origin (0,0) = top-left corner of the tile_bounds bounding box.
Do not use any other characters until the server documents them.
### #582 — The Terminal tile map
**Source materials:**
- `#311` (done): Spatial layout — The Terminal. Tile-level floor plan with sightline analysis. Sprint 12.
- `D-093`: Terminal dimensions = **44×28 visual tiles**. Zone palette surface hex: `#7a8490`, fog tint: `#0d1520`.
- Access tiers: main corridor = public, work floor = credentialed, restricted storage = restricted.
- Key spatial features:
- Scanner bays (entry — public)
- Main corridor (chokepoint — high sightline value)
- Manifest processing (work floor)
- Break room (social site — NPC off-hours presence)
- Supervisor office (window faces main corridor — LOS design requirement)
- Restricted storage (smuggling ops — `R` tiles for access restriction)
**Output:** Complete `content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-terminal.yaml` with:
- `canonical_id: krenn.sova.transit.location.the-terminal`
- `display_name: "The Terminal"`
- `tile_bounds: { x_min: 0, y_min: 0, x_max: 43, y_max: 27, z: 1 }` (z=1 per D-093)
- `tiles: [...]` — the 44×28 grid
- `sightlines: { open: false, notes: "supervisor office window faces main corridor; LOS critical for investigation" }`
- `social_site: "logistics-hub"` (links to the template)
**Sightline requirement:** The supervisor office window must be placed so that the player standing in the main corridor has LOS to the office interior. This is investigatively critical — it is how the player first suspects the supervisor. Do not place it behind a wall that would block shadowcasting.
### #583 — The Last Shift, maintenance corridors, gate corridor
Three YAML files. All blocked on #577 tile format; can author all three in parallel once format is confirmed.
**The Last Shift (`the-last-shift.yaml`)**
- Source: #312 (done), Sprint 12
- D-093: **28×22 visual tiles + 6m east extension** (= ~6 visual tiles). Total footprint: ~34×22.
- Zone palette: `#6b4018` (warm dark brown — converted maintenance area), fog tint: `#200c04`
- Key features: long bar counter (high visibility — staff can see all tables), corner booth (observation premium — sees bar + card table + entrance + back room), scattered tables, news ticker display (`F` tiles), back room with alley exit (`R` or `F` tiles — navigable, but the back exit is the key traversal route)
- z=1 (main structure per D-093)
**Maintenance corridors (`maintenance-corridors.yaml`)**
- Source: #313 (done), Sprint 12
- D-093: Corridor width = 2 visual tiles (maintenance standard). z=0 (Era 1, below main structures).
- Zone palette: `#4e5054` (dark utilitarian grey), fog tint: `#101214`
- Key features: restricted storage (shared with Terminal — use `R` tiles), maintenance hatch (connects z=0 to z=1 — note: cross-z connection is a future movement feature; for now just tile the spaces), transition corridor 40m between hub and bar district (~40 visual tiles length at 1m/tile)
- Design principle (D-093 G-08): the ring operates here; all spaces must read as mundane maintenance unless you know what to look for. No obviously suspicious tile arrangements.
**Gate corridor (`gate-corridor.yaml`) — new file**
- Source: D-093 gate cluster zone spec
- D-093: Gate cluster **40×32 visual tiles**, z=1 main + z=2 observation gallery
- Zone palette: `#b8bec4` (neutral cool — institutional grey), fog tint: `#0a1222`
- Seven zones (author these as continuous tile grid, walls between zones):
- Aperture chamber: 8×4 (restricted — `R` tiles)
- Freight staging: 24×8 (private — use `F` tiles, access enforced by NPCs not tile kind)
- Passenger arrival: 12×8 (semi-public — `F`)
- Freight customs: 20×10 (semi-private, 3–5 lanes of 2-visual-tile width separated by wall stubs)
- Pedestrian customs: 12×10 (semi-public, 3 lanes of 1-visual-tile width)
- Gate concourse: 40×8 (public — wide open, 8vt corridor width per D-093)
- Observation gallery: z=2, 32×10 (Commission-only — `R` tiles; separate tile_bounds entry with z:2)
- D-093 cross-z LOS: gallery rail = transparent low wall. Model this as a separate z=2 tile grid with `R` floor tiles (navigable but restricted access). The LOS cross-z behavior is handled by the server; just provide the tile data.
- Add `gate-corridor` to `content/campaigns/main/systems/krenn/stations/sova/districts/transit/district.yaml` locations list.
**Cross-z note:** Author two sections in the gate-corridor YAML if the format supports multiple `tile_bounds` (one at z=1, one at z=2). If the format only supports a single z-level per Location, author two separate files: `gate-ground.yaml` and `gate-gallery.yaml`. Confirm with Dudley when the format is published.
---
## Dependency Chain
```
#577 (server: tile format published, day 1-2)
→ #582 (Terminal) — can start same day format is confirmed
→ #583 (Last Shift + maintenance + gate) — parallel with #582
```
Both tile authoring tickets can run in parallel once #577 publishes the format. Expect 2–3 days of authoring work per ticket given the complexity of the gate corridor.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(visual): author tile maps for Sova Transit District" \
--description "body" --base main --head visual
```
---
## Sprint Completion (Visual Criteria)
1. `the-terminal.yaml` has complete tile data — 44×28 grid, all zones represented, sightlines documented.
2. `the-last-shift.yaml` has complete tile data — corner booth placement satisfies observation affordance.
3. `maintenance-corridors.yaml` has complete tile data — z=0, maintenance aesthetic, restricted storage marked.
4. `gate-corridor.yaml` (or equivalent) has complete tile data for all 7 zones including z=2 observation gallery.
5. `district.yaml` locations list updated to include gate-corridor.
6. Server can boot production startup and load tile data from all four locations without error.
+149
View File
@@ -0,0 +1,149 @@
# Sprint 24: Signal — Client Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Client work this sprint wires the player-facing signal
> that makes the simulation legible as a story: the character select screen, the triangle
> activation response, and the news ticker. All three must land before #593 (playthrough proof)
> can be filed as done. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #588 | Character archetype selection — client: character select screen before session start | #587 (server: archetype in StartupMessage) |
| #590 | Triangle activation consumer — client: react to triangle_crisis_events (monologue chime + urgent overlay) | #589 (server: tell escalation emitted) |
| #592 | News ticker — client: scrolling ticker HUD element in The Last Shift zone | #591 (server: ticker in snapshot) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests this sprint's work must satisfy), D-039 (wow moments — #1 Arrival: opening monologue; #5 News Ticker Gut-Punch: same ticker, opposite monologue reactions)
- `decisions/architecture.md` — D-020 (ObserverSnapshot is the only data crossing IPC; StartupMessage is the client→server init message; PROTOCOL_VERSION gates wire compatibility), D-042 (UI microcopy in `client/data/ui-strings.yaml` via UIStrings autoload)
- `decisions/content.md` — D-032 (separate monologue pools per character — client does not select the pool; the character string in StartupMessage drives server-side selection), D-074 (audio aesthetic — monologue chime = insert-tech: synthetic, precise, no reverb)
- `decisions/perception.md` — D-067 (recognition chime fires at onset of cognitive delay — `sfx_monologue_chime_urgent.ogg` is the correct asset for triangle activation), D-016 (internal monologue as perception bridge — client only displays what server sends; no client-side monologue logic)
---
## Notes
### #588 — Character archetype select screen
**What exists:** `client/ui/main_menu.gd` (130 lines) — "New Game" button triggers `SessionManager.new_game()` which creates a save directory and seeds `GameState.world_seed`. It then loads `GAME_SCENE` directly, with no character selection step. `client/scripts/autoloads/session_manager.gd` — `new_game()` returns a `game_id` but does not record which archetype was chosen. `client/scripts/autoloads/game_state.gd` — check whether a `character_archetype` field already exists (likely not — add it). `CharacterArchetype` is a server-side enum; the client needs to record the chosen value as a string (`"smuggler"` or `"detective"`) and include it in `StartupMessage` sent over IPC.
**What to deliver:**
1. `GameState.character_archetype: String` — new field, default `"detective"`. Persisted alongside `world_seed` in the save directory (`user://saves/<game-id>/character.txt` or extend the existing seed file format).
2. Character select scene — insert a step between "New Game" and loading `main.tscn`. This can be a new scene (`client/scenes/character_select.tscn`) or a modal panel within `main_menu.tscn`. Show two options: **Smuggler** and **Detective**. Each option shows the character name, a one-line role description, and a two-line tone description (see below). On selection, set `GameState.character_archetype`, then proceed to `main.tscn`.
**Smuggler card:**
- Name: `Smuggler`
- Role: `Freight logistics worker — Sova Transit`
- Tone: `Insider access. Social camouflage. The ring is your daily life.`
**Detective card:**
- Name: `Detective`
- Role: `Commission investigator — External assignment`
- Tone: `Institutional authority. Analytical lattice. You were sent here.`
These strings belong in `client/data/ui-strings.yaml` (D-042), not hardcoded in GDScript.
3. `Protocol.encode_startup_message()` update — add `character_archetype` to the StartupMessage dict before it is serialized. The server's `StartupMessage` struct now has `pub character_archetype: CharacterArchetype` (#587). Map client string `"smuggler"` → server enum variant `Smuggler`. In MessagePack/GDScript, this is just a string field added to the dict: `{ "world_seed": ..., "character_archetype": "Smuggler" }`.
4. `Protocol.PROTOCOL_VERSION = 19` — bump to match server #587. The client must send the new version on handshake. This is a **hard coordination point** with Dudley — client and server PRs must land together or in the same merge window. A version mismatch will crash the connection on the handshake check.
**UI constraints:** The character select screen must feel intentional, not an afterthought. Two full-width cards, dark background, character name in the sprint's color palette (consistent with main menu). No portraits (art is deferred). Cards are selectable via keyboard (left/right arrows) and mouse click. The selection is confirmed with Enter or a "Begin" button. ESC cancels back to the main menu without creating a save directory.
**Non-obvious gotcha:** `SessionManager.new_game()` currently creates the save directory before any game scene loads. The character select step happens after `new_game()` creates the directory but before the game scene loads. `GameState.character_archetype` must be set before `SimBridge` sends the `StartupMessage` — which happens when `main.tscn` is ready and `SimBridge._ready()` connects to the server. Verify the ordering: `new_game()` → character select panel → user picks archetype → `GameState.character_archetype` set → `main.tscn` loads → `SimBridge._ready()` fires → `StartupMessage` includes archetype.
**Blocked by:** #587 (server must define `character_archetype` field in `StartupMessage` before client serialization is finalized).
---
### #590 — Triangle activation consumer
**What exists:** `client/scripts/snapshot_event_router.gd` — routes snapshot fields to registered handlers. `client/scripts/main.gd` — registers handlers on `_router`. `client/scripts/autoloads/sim_bridge.gd` — `_on_snapshot_received()` decodes and emits snapshot. `client/scripts/protocol/protocol.gd` — `decode_snapshot()` returns a dict from the MessagePack bytes. The server snapshot wire type (`ObserverSnapshotWire`) has a `triangle_crisis_events: Vec<TriangleCrisisEventWire>` field (see `server/src/bridge/types.rs` line ~181). This field is present in the MessagePack output. **The client currently ignores it entirely** — there is no decode path for `triangle_crisis_events` in `protocol.gd` and no handler registered in `main.gd`.
**What to deliver:**
1. **Decode `triangle_crisis_events`** in `protocol.gd` `decode_snapshot()`. The field is an array of dicts, each with at minimum `{ "triangle_id": int }`. Add it to the returned snapshot dict as `"triangle_crisis_events": Array`.
2. **Handle activation in `main.gd`** — register a handler that reads `triangle_crisis_events` from the snapshot. When the array is non-empty (at least one event), fire the urgent monologue chime: `AudioManager.play_one_shot(AudioManager.CHIME_RECOGNITION, AudioManager.BUS_UI_SOUNDS)` — wait, check the constant name. The correct asset is `sfx_monologue_chime_urgent.ogg` (D-038, D-067 "sharper variant for contradiction/anomaly"). `AudioManager` has `const CHIME_RECOGNITION := "sfx_monologue_chime"` — add `const CHIME_ACTIVATION := "sfx_monologue_chime_urgent"` if it doesn't exist, then call `AudioManager.play_one_shot(CHIME_ACTIVATION, BUS_UI_SOUNDS)`.
3. **Deduplication** — the triangle activation is a one-shot event (v0.1 fires once per session per D-072/D-089). The client must not fire the chime on every subsequent tick that includes the event in the array. Track activated triangle IDs in a local `Set` in `main.gd`. If `triangle_id` is already in the set, skip. Add to set on first encounter.
4. **No overlay UI** — the monologue chime is the client-side signal. The copy team (#597) authors the proximity monologue lines that fire when the player observes the activated NPC's `tell_state: RoutineDeviation`. The client does not need to render a special overlay or notification — the tell state on the entity and the subsequent proximity monologue are the visible consequence. Keep client reaction to: chime + deduplication tracking only.
**Why no overlay:** D-039 wow moment #2 ("The Character's Eye") is about the monologue noticing something the player didn't. Adding a UI overlay would make it a notification, not a character observation. The feel is: you're wandering near Kael, suddenly you hear the chime — then the next monologue line is your character's internal voice noticing something is off. The server sends the `tell_state: RoutineDeviation` on the NPC entity; the client's entity renderer already renders this as visible entity data that can trigger `observe_npc` monologue.
**Blocked by:** #589 (server must send non-empty `triangle_crisis_events` for client to handle).
---
### #592 — News ticker HUD
**What exists:** `client/ui/hud.gd` and `hud.tscn` — main HUD container. `client/ui/time_display.gd` — insert-style time display already wired via `_router.register_always(time_display.update_from_state)` in `main.gd`. No ticker node or script exists. Server snapshot will carry `current_ticker: Optional<{ id, text, category }>` when player is in the bar zone (#591).
**What to deliver:**
1. `client/ui/news_ticker.gd` + `news_ticker.tscn` — a horizontal scrolling text bar. Design: narrow strip (24–32px tall), anchored top of screen or bottom above the dialogue box, full width. Background: dark semi-transparent (`Color(0.05, 0.05, 0.07, 0.75)`). Text: scrolls left at a constant rate (~60px/sec). Text content: the `text` field from `current_ticker`. When `current_ticker` is `null` (player is not in bar zone), the ticker hides itself (`visible = false`).
2. Wire in `main.gd` — add `@onready var news_ticker = $UILayer/NewsTicker` and register: `_router.register_always(news_ticker.update_from_state)`. Implement `news_ticker.update_from_state(snapshot: Dictionary)`: read `snapshot.get("current_ticker")`, update text if changed, show/hide based on null.
3. **Insert overlay compatibility** — the ticker lives on `UILayer` (z-layer 7 per D-049). When the insert overlay is open (`GameState.insert_active = true`), the ticker should NOT be hidden — the news terminal is a real-world object the player can see while their insert is open. Do not call `set_insert_active` on the ticker.
4. **Scrolling behavior** — the headline scrolls in from the right and exits left. When it exits, it restarts from the right with the same text (the server rotates the headline every 200 ticks; client just loops whatever it currently has). No crossfade, no fade-in. Pure marquee.
**UI location:** Confirm with the sprint visual check that the ticker does not occlude the time display (top-right insert) or the monologue display (top-center). If there is a conflict, anchor the ticker at the bottom-center above the dialogue box, 4px margin above.
**Blocked by:** #591 (server must send `current_ticker` field in snapshot before client has real data to render; before that, the ticker renders nothing and stays hidden).
---
## Dependency Chain
```
#587 (server: archetype in StartupMessage)
└→ #588 (character select screen) ← start after #587 is merged
└→ PROTOCOL_VERSION 17→19 bump (coordinate with server)
#589 (server: tell escalation)
└→ #590 (triangle activation consumer) ← start after #589 is merged
#591 (server: ticker in snapshot)
└→ #592 (news ticker HUD) ← start after #591 is merged
#588 + #590 + #592 → #593 (playthrough proof — server ticket)
```
All three client tickets are blocked on their respective server tickets. Start with `protocol.gd` decode additions speculatively (no server data yet — verify against `server/src/bridge/types.rs` for field names), then wire the handlers once server branches are merged to main.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): character archetype select screen" \
--description "body" --base main --head client
```
---
## Sprint Completion (Client Criteria)
1. From main menu, "New Game" → character select screen appears. Both cards render. Keyboard and mouse selection work. ESC cancels without creating a save directory.
2. Selected archetype is persisted in `GameState.character_archetype` and sent in `StartupMessage`. Server receives correct archetype (verify via debug console `status` — it should report the active archetype if Dudley adds it to the status response).
3. `PROTOCOL_VERSION = 19` — client matches server. Connection handshake succeeds.
4. When `triangle_crisis_events` is non-empty in snapshot, `sfx_monologue_chime_urgent.ogg` fires once. Does not re-fire on subsequent ticks.
5. News ticker visible and scrolling in The Last Shift zone. Hidden in all other zones. Text matches the server-sent headline.
6. `make test-client` green on client branch. No regressions in existing test suite.
+147
View File
@@ -0,0 +1,147 @@
# Sprint 24: Signal — Copy Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `copy`
**Agents:** Mellanie (dialogue), Paula (narrative), Gestalt (systems)
> **This is the capstone sprint for v0.1.** The lines authored here are the player's first
> experience of the storyteller doing its job. When the triangle activates and Kael's behavior
> shifts, the character's internal voice is the signal. These lines carry D-039 wow moment #2
> ("The Character's Eye") — the moment the monologue notices something the player didn't.
> Sprint 25 is playtest. Get these lines right.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #597 | Triangle activation — copy: author 3–5 proximity monologue lines for TriangleActivated (per archetype) | — |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-039 (wow moment #2: "The Character's Eye" — monologue flags something the player didn't notice; urgent chime fires), D-027 (vertical slice criterion: the observe→notice→follow→discover sequence must emerge from systems, not scripts)
- `decisions/content.md` — D-032 (separate monologue pools per character — no shared lines between smuggler and detective), D-035 (tag taxonomy: `trigger: observe_npc`, `situation: [triangle_activated]`, `character`, `prerequisite` fields required), D-090 (PC voice registers — smuggler: contracted, street-cadenced, risk-calculating; detective: analytical, institutional, uncontracted)
- `decisions/content.md` — D-016 (monologue functions: perception bridge, atmosphere, diegetic hint, unreliable narrator — these lines are observation, not exposition), D-034 (THE FRIEND: Kael Davan is smuggler's FRIEND; Sera Venn is detective's FRIEND — these are the primary triangle anchor NPCs)
- `decisions/content.md` — D-024 (tell system — `RoutineDeviation` tell fires when NPC is off schedule; these lines should feel like the character noticing the deviation, not naming the conspiracy)
---
## Open Questions to Resolve Early
None blocking this ticket. The tag schema is fully specified (D-035). The character voice registers are documented (D-090). The NPC identities are confirmed (D-034). Author without waiting for server/client tickets to land — the content files are independent of the implementation.
---
## Notes
### #597 — Triangle activation proximity monologue lines
**What exists:** The monologue pool system (`server/src/simulation/monologue.rs`) selects lines by `trigger`, `character`, `situation`, and `prerequisite`. The `observe_npc` trigger fires when the player is proximate to a specific NPC and has LOS to them. `RoutineDeviation` tell state is the server signal that a triangle anchor NPC is activated. The system can gate monologue lines on situation tags that correspond to game state — `triangle_activated` is a valid situation tag that the server can emit when `TriangleActivatedQueue` is non-empty.
**What to deliver:** 3–5 monologue lines per character (smuggler and detective), placed in the correct pool files.
**File locations:**
- Smuggler: `content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml`
- Detective: `content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml`
(Kael and Torek are Terminal NPCs. Sera is also a Terminal presence. These lines fire when the player observes those NPCs post-activation while at The Terminal or maintenance corridors.)
**Tag specification (D-035 required fields):**
```yaml
- id: pc-smuggler_m_s_NNN # NNN = next available index in the smuggler pool
text: "..."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious] # or [anxious] depending on line content
priority: 8 # higher than ambient lines; lower than opening hook (10)
cooldown: 9999 # fires once per activation per session
tags: [triangle-signal, tell-observation]
prerequisite:
npc_in_los: true # only fires when actively observing an NPC
```
**Smuggler lines — Kael Davan (triangle anchor: T1 hub-power)**
The smuggler knows Kael. Kael is a colleague, possibly a friend. The lines must feel like noticing something personal about a familiar person, not flagging a conspiracy suspect. The smuggler's register (D-090): contracted, casual, risk-reading.
Three beats to cover in 3–5 lines (one line per beat, no doubling):
1. Physical observation — Kael's behavior is off in a specific, observable way (posture, timing, direction).
2. Internal rationalization — the smuggler finds a mundane explanation first. This is deniable.
3. Doubt — the rationalization doesn't quite hold. The smuggler can't name what's wrong. The reader can.
Example tone (do not use as final lines — author fresh):
- Beat 1: "Kael's on the main corridor. He doesn't usually work this route." (Too on-the-nose; soften)
- Beat 2: "Could just be a schedule swap. Voss does that sometimes."
- Beat 3: "...but Kael doesn't swap shifts."
These are tone illustrations, not copy. Paula and Mellanie should author the actual lines.
**Detective lines — Sera Venn and Torek Lintar (triangle anchor: T2 informant-question)**
The detective is external — these are analytical observations about NPCs whose behavior creates a logical anomaly in the investigation pattern. Detective register (D-090): uncontracted, institutional framing, evidence-cataloguing internal voice.
Three beats:
1. Pattern recognition — the detective logs a behavioral deviation as data.
2. Hypothesis formation — what does this deviation imply? (stated as a question, not a conclusion)
3. Procedural next step — the detective's internal instinct is to act, not just observe.
Example tone:
- Beat 1: "Sera avoided eye contact with Torek again. Third occurrence in four observations."
- Beat 2: "If she knows something about his manifest discrepancy, why the silence?"
- Beat 3: "Worth a conversation. But not here."
**What these lines must NOT do:**
- Name the conspiracy directly ("Kael is in the ring" / "Torek is covering evidence")
- Be omniscient — the character observes behavior, not motive
- Be too long — 1–2 sentences maximum per line; monologue is a flash of interiority
- Repeat vocabulary across lines — each line should use different sensory or cognitive entry
**Verification:** Run the line previewer CLI to confirm schema compliance before committing:
```bash
tooling/db/sqlite-query 'SELECT * FROM ...' # check via line previewer if available
# Or: server/target/debug/line-previewer path/to/the-terminal.yaml
```
Confirm with `make validate-content` that the YAML parses without errors.
---
## Dependency Chain
```
#597 (proximity monologue lines) — no blockers, author immediately
#597 → #593 (playthrough proof — must land before sprint is closed)
```
Start authoring #597 immediately. It is unblocked. The lines need to be in the content files before the playthrough proof (#593) can be run.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(copy): triangle activation proximity monologue lines" \
--description "body" --base main --head copy
```
---
## Sprint Completion (Copy Criteria)
1. 3–5 smuggler monologue lines in `monologue/smuggler/the-terminal.yaml` with `situation: [triangle_activated]`, correct tag schema (D-035), `cooldown: 9999`, `trigger: observe_npc`.
2. 3–5 detective monologue lines in `monologue/detective/the-terminal.yaml` with the same schema.
3. `make validate-content` passes — YAML parses, required fields present, no FactId typos.
4. Voice consistency: smuggler lines are contracted, risk-reading; detective lines are analytical, institutional. Paula reviews before PR.
5. Lines do not name the conspiracy — they observe behavior and feel personal to the character.
+120
View File
@@ -0,0 +1,120 @@
# Sprint 24: Signal — Joint / Integration
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Agents:** All implementation agents (Dudley, Tyre, Hoshe, Stig, Mellanie, Paula, Gestalt)
> **This is the capstone sprint for v0.1.** Sprint 23 built the world. Sprint 24 makes it
> legible as a story. Sprint 25 is playtest. Every ticket in this sprint feeds into a single
> proof: a real human can boot the game, pick a character, play for 30 minutes, and experience
> the storyteller doing its job — without scripted interventions or debug shortcuts.
---
## Pre-Sprint Actions
These must happen at sprint start, before implementation tickets begin:
| Action | Owner | Blocks |
|--------|-------|--------|
| Confirm `StartupMessage` wire format with `character_archetype` field | Dudley + Stig sync (day 1) | #588 — client serialization cannot be finalized until server field is published in `bridge/types.rs` |
| Confirm PROTOCOL_VERSION bump: 18 → 19 | Dudley + Stig | #587 server + #588 client must land together — version mismatch crashes handshake |
| Confirm `triangle_crisis_events` decode path for `TriangleCrisisEventWire` field names | Dudley + Stig sync (day 1) | #590 — client `protocol.gd` decode speculates from `bridge/types.rs`; confirm field names before merging |
| Confirm `current_ticker` snapshot field structure (`id`, `text`, `category`) | Dudley + Stig sync (day 1) | #592 — client ticker decode needs field names from server `bridge/types.rs` |
---
## Cross-Team Dependencies
| Dependency | Direction | Notes |
|------------|-----------|-------|
| `character_archetype` in StartupMessage | Server (#587) → Client (#588) | Client cannot send archetype until server defines the field. Stig reads `bridge/types.rs` for the field name before finalizing `Protocol.encode_startup_message()`. |
| PROTOCOL_VERSION 18 → 19 | Server (#587) ↔ Client (#588) | Both must land in the same merge window. A mismatch crashes the handshake. |
| `RoutineDeviation` tell in snapshot | Server (#589) → Client (#590) | Client registers `triangle_crisis_events` handler; server populates it after tell escalation is wired. |
| `current_ticker` in snapshot | Server (#591) → Client (#592) | Client ticker hides when field is absent (null). Client can be merged before server; it simply renders nothing until server sends data. |
| Triangle activation monologue lines | Copy (#597) → Server (#593) | Lines must be in YAML files before playthrough proof run. Copy is unblocked — merge first. |
| Playthrough proof | All tickets → Server (#593) | #593 is the convergence gate. Cannot be filed done until all upstream tickets are merged and CI is green across all branches. |
---
## Housekeeping Done at Sprint Planning
The following tickets were closed during planning as their work was already complete:
| # | Reason |
|---|--------|
| #38 | Client-Server Integration epic — all 11 children done across Sprints 1–22 |
| #369 | v0.1 Content Scoping Workshop Outputs — all 39 children done |
| #455 | QA Strategy & Test Infrastructure — all 59 children done |
| #575 | LOS boundary bug — fixed by #584/#585 in Sprint 23 |
| #596 | Opening monologue content — fully authored in Sprint 12 (#299 smuggler, #300 detective) |
---
## Sprint Completion Proof
The sprint is done when **all of the following are observable in a live production session** (no `--gauntlet`, no `SR_TEST=1`, no forced debug shortcuts):
1. **Character selection works.** From the main menu, click "New Game." A character select screen appears with two cards: Smuggler and Detective. Both keyboard and mouse selection work. ESC cancels without creating a save. Selecting a character proceeds to the game.
2. **Opening monologue is character-correct.** The first monologue line that fires on session start matches the selected archetype. Smuggler opening: contracted, dock-worker voice, insider framing. Detective opening: analytical, institutional framing, uncontracted. Both fire within the first 5 seconds of game load.
3. **Storyteller activates naturally.** Play as Smuggler. Walk to The Terminal. Observe Kael for ~3 game-minutes (no teleport, no debug shortcut). Eventually (after ~30 game-minutes real or via debug `contaminate`): the urgent monologue chime fires. The next time you approach Kael, a proximity monologue line fires from the `triangle_activated` situation pool.
4. **Tell state is observable.** After activation, stand near Kael. The debug console `npc <kael_entity_id>` reports `tell_state: RoutineDeviation`. The entity renderer colors/indicators for Kael reflect the tell (future animation deferred — tell state emitted is sufficient for v0.1).
5. **News ticker displays in The Last Shift.** Walk to The Last Shift bar. A scrolling text headline is visible in the HUD. Leaving the bar hides it. Returning shows a headline (possibly different, if 200 ticks have elapsed).
6. **CI green across all branches.** `make ci` passes on `main`, `server`, `client`, `copy` branches. No regressions.
---
## Test Plan Alignment (D-030)
Sprint 24 is Phase 3 territory (D-030 Phase 3: CauseChain verification + divergent snapshots):
- **#587/#588:** Protocol round-trip test — `StartupMessage { character_archetype: Smuggler }` survives serialize/deserialize. Phase 1 (data structure).
- **#589:** Unit test — after `TriangleActivatedQueue` is populated, triangle NPCs have `RoutineDeviation`; `derive_tell_state()` returns `RoutineDeviation`. Phase 1.
- **#591:** Unit test — `TickerPool` loads 30 headlines from YAML, rotation advances deterministically with `SimRng`. Phase 1.
- **#595:** Integration test — 1-tick simulation with `StartupMessage { character_archetype: Smuggler }` emits opening monologue line from smuggler pool (not detective pool). Phase 2.
- **#590:** Client test — mock snapshot with `triangle_crisis_events: [{ triangle_id: 1 }]` triggers `CHIME_ACTIVATION` play call once; second snapshot with same triangle_id does not retrigger. Phase 2.
- **#593:** End-to-end integration test — `test_v0_1_integration_playthrough` (server `tests/` directory). Boot server, send Smuggler startup message, advance to contamination via debug command, assert TriangleActivated and RoutineDeviation in golden snapshot. Phase 3.
---
## Open Questions
| ID | Question | Blocks | Action |
|----|----------|--------|--------|
| Q-052 | Storyteller hint delivery channels | — | Resolved at v0.1 scope this sprint. #589 implements channel 1 (behavioral tell escalation), #597 implements channel 7 (proximity monologue). Channels 2 (environmental change) and 4 (overheard NPC conversation) deferred to v0.2. File resolution note in `decisions/questions-content.md` after sprint. |
---
## PR Merge Order
To avoid conflicts on shared files (`bridge/types.rs`, `protocol.gd`, `PROTOCOL_VERSION`):
1. Copy PR (#597) — no code dependencies; merge first. Lines must be in `main` before playthrough proof runs.
2. Server PRs (#589, #591, #594) — no client dependencies; merge in any order. These are independent.
3. **Server PR #587 (StartupMessage + archetype) + Client PR #588 (character select)** — must land together. Both bump `PROTOCOL_VERSION` to 19. Coordinate merge timing.
4. Client PR #590 (triangle consumer) — merge after server #589 is in main.
5. Client PR #592 (ticker HUD) — can merge before server #591 (renders nothing when field absent); merge after for clean integration test.
6. Server PR #595 (opening monologue gate) — merge after server #587 is in main.
7. Server PR #593 (playthrough proof) — last to merge. Requires all upstream PRs green.
**Critical:** #587 and #588 share the PROTOCOL_VERSION bump. Do not merge one without the other.
---
## v0.1 Readiness After This Sprint
After Sprint 24 ships, the vertical slice (D-027) satisfies:
| D-027 Criterion | Sprint 24 Delivery | Status |
|----------------|-------------------|--------|
| 30 minutes of daily-life breathing room before contamination | Contamination delay (Sprint 22 #254) + storyteller lifecycle (Sprint 23 #572) | Done — Sprint 22/23 |
| Both playthroughs feel fundamentally different | Character archetype select (#587/#588) + archetype-gated monologue (#595) + character voice (#597) | Done — this sprint |
| Player names an NPC they felt conflicted about | Kael/Sera content (Sprint 12–22) + tell escalation (#589) + proximity monologue (#597) | Done — this sprint |
| Observe→notice→follow→discover emerges from systems | Tell escalation (#589) + proximity monologue (#597) + existing dialogue/knowledge graph | Done — this sprint |
Sprint 25 will address wow moments #3 (THE FRIEND's Contradiction) and #6 (The Quiet Moment) — the two that require deeper playtest-driven tuning. The core loop is complete after Sprint 24.
+199
View File
@@ -0,0 +1,199 @@
# Sprint 24: Signal — Server Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Everything server-side must converge on a production
> playthrough: correct character spawned, opening monologue fired, storyteller active, triangle
> escalation observable. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #587 | Character archetype — server: accept archetype in StartupMessage, spawn correct PC | — |
| #589 | Triangle activation consumer — server: behavioral tell escalation on TriangleActivated | — |
| #591 | News ticker — server: load ticker YAML, emit current headline in snapshot | — |
| #595 | Opening monologue trigger — server: emit opening lines at session start based on archetype | #587 |
| #593 | v0.1 playthrough proof: full session from main menu through storyteller activation | #587, #589, #591, #595 (+ client #588, #590, #592 + copy #597) |
| #594 | Tile data model design — produce D-record for extensible tile properties | — (design only, unblocks post-v0.1) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests the playthrough must satisfy), D-039 (6 wow moments — opening arrival, character's eye, FRIEND contradiction, divergence reveal, news ticker gut-punch, quiet moment)
- `decisions/content.md` — D-032 (separate monologue pools: `character` tag is a hard partition, not a filter), D-035 (tag taxonomy: `trigger`, `character`, `prerequisite` fields on monologue lines), D-023 (three-tier content model — storyteller activates Tier 1 modules based on engagement)
- `decisions/content.md` — D-024 (NPC 10-axis model — tell system axis 9; tells flow from simulation state not authored), D-036 (Sova Transit District — The Terminal, The Last Shift, news ticker content at `ticker/the-last-shift.yaml`)
- `decisions/architecture.md` — D-020 (StartupMessage is the only initialization crossing IPC; PROTOCOL_VERSION gates wire compatibility), D-041 (knowledge graph data model — KnowledgeConfidence confidence levels), D-010 principle 3 (no player identity baked into game loop — archetype is a configuration, not a special case)
---
## Open Questions to Resolve Early
- **Q-052: Storyteller hint delivery channels** — #589 implements channels 1 (behavioral tell escalation) and 7 (proximity monologue, via copy #597). Environmental change (channel 2) and overheard NPC conversation (channel 4) are deferred to Sprint 25. This sprint resolves Q-052 at v0.1 scope. No design discussion needed — the channel inventory is decided; implementation scope is constrained to what's achievable this sprint.
---
## Notes
### #587 — Character archetype in StartupMessage
**What exists:** `StartupMessage` in `server/src/bridge/types.rs` has one field: `pub world_seed: u64`. The production startup path initializes all NPCs and spawns the player entity without any archetype selection — the player entity carries `CharacterArchetype::Detective` by default (see `server/src/simulation/monologue.rs` line ~174: `character: "detective".to_string()`). `CharacterArchetype` enum is already defined in `bridge/types.rs` with `Smuggler` and `Detective` variants (used by phase-2 verb filter in `server/src/perception/observer/mod.rs`).
**What to deliver:**
1. Add `pub character_archetype: CharacterArchetype` to `StartupMessage`. Default to `Detective` if absent during deserialization (backward-compatible via `#[serde(default)]`).
2. In the production startup path (`server/src/main.rs` or `server/src/content/spawn.rs`), read `startup_msg.character_archetype` and insert the correct `CharacterArchetype` component on the player entity. The player entity is currently spawned without an archetype component — insert it here.
3. In `server/src/simulation/monologue.rs`, read the `CharacterArchetype` component from the player entity at `MonologueState` initialization (or on first tick) and set `MonologueState.character` from it: `"smuggler"` or `"detective"`. This is the string that gates all monologue pool selection.
4. Bump `PROTOCOL_VERSION` to 19. The new field in `StartupMessage` is a breaking change — old clients send a message that omits `character_archetype`, new server will default it correctly, but old servers receiving the new format will fail. Coordinate with Stig (#588) on timing.
**Gotcha:** `CharacterArchetype` already serializes via serde. The existing `impl From<CharacterArchetype> for ObserverSnapshot` path in bridge tests validates round-trips — make sure the new `StartupMessage` test also covers the default case (`Smuggler` serializes and round-trips; missing field deserializes as `Detective`).
**Unblocks:** #595 (opening monologue), #588 (client select screen can now send the archetype).
---
### #589 — Triangle activation consumer: behavioral tell escalation
**What exists:** `server/src/npc/tell_state.rs` — `TellCategory` enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation) and `derive_tell_state()` function. Tell state is derived from NPC axis values each tick and emitted in `ObserverSnapshot.entities[].tell_state`. `RoutineDeviation` component exists. The storyteller emits `TriangleActivated { triangle_id, npc_entity }` into `TriangleActivatedQueue`.
**What to deliver:** A system `escalate_tells_on_activation()` that:
1. Drains `TriangleActivatedQueue` each tick (non-destructively — queue must still be readable by other consumers; use `events()` pattern or check if existing drain is appropriate).
2. For each `TriangleActivatedEvent`, find the anchor NPC entity (`event.npc_entity`) and the 1-2 NPCs who are in the same triangle (via `TriangleState` query). These are the triangle's NPCs.
3. Insert a `RoutineDeviation` component on all triangle NPCs. `RoutineDeviation` is the strongest tell category per `derive_tell_state()` priority order — it overrides Nervous/Angry/Guarded. This makes the triangle NPCs immediately observable as anomalous.
4. The `RoutineDeviation` component should carry a `expires_at_tick: u64` field (if not already present) so it can be removed after a configurable window (suggest `TELL_ESCALATION_DURATION_TICKS = 300` = 30 game-minutes). Add a system to remove expired `RoutineDeviation` components.
**Why this is the right channel:** D-024 axis 9 (tell system) is a simulation output, not authored content. The `RoutineDeviation` tell fires when the NPC is off their usual schedule — which is exactly true post-activation (the triangle is hot). No new content required. Client sees it as `tell_state: RoutineDeviation` on the visible entity. Copy (#597) authors the monologue lines the client fires when the player observes this tell.
**Integration point:** `TriangleActivatedQueue` is in `server/src/storyteller/mod.rs`. The new system should live in `server/src/storyteller/` or `server/src/simulation/pressure.rs` — either is appropriate. Register in `StorytellerPlugin.build()`.
**Test:** `cargo test` — verify that after `TriangleActivatedQueue` is populated with a test event, triangle NPCs have `RoutineDeviation` inserted and `derive_tell_state()` returns `RoutineDeviation` for them.
---
### #591 — News ticker: server-side
**What exists:** `content/campaigns/main/systems/krenn/stations/sova/districts/transit/ticker/the-last-shift.yaml` — 30 authored headlines (freight, politics, infrastructure, sports, commission, community categories). The YAML is fully authored (Sprint 12, #306). It is NOT loaded at runtime — the content loader (`server/src/content/loader.rs`) does not parse ticker files. `ObserverSnapshotWire` in `server/src/bridge/types.rs` has no ticker field.
**What to deliver:**
1. `TickerLine` struct: `id: String`, `text: String`, `category: String`. Add to `bridge/types.rs`.
2. Add `current_ticker: Option<TickerLine>` to `ObserverSnapshotWire`. Only populated when player is in The Last Shift zone (zone_id `"bar"` — check `server/src/simulation/zone.rs`). `None` in all other zones.
3. `TickerPool` resource: loads `ticker/the-last-shift.yaml` at startup via the content loader. Holds all 30 headlines. Uses `SimRng` to advance to a new headline every `TICKER_ROTATION_TICKS = 200` ticks (20 game-minutes). Deterministic under D-010 — always use `SimRng`, never system randomness.
4. In the observer snapshot system (`server/src/perception/observer/mod.rs`), read `TickerPool` and populate `current_ticker` when player zone is `"bar"`.
**Gotcha:** The ticker rotation must use `SimRng` (the seeded bevy resource), not `rand::thread_rng()`. D-010 principle 4 — deterministic simulation. A ticker rotation driven by `thread_rng()` would produce different headlines on replay, breaking golden-file tests.
**Content note:** The ticker content file at `ticker/the-last-shift.yaml` uses a `dual_lens` field per headline (separate monologue notes for smuggler vs detective perspective). These notes are for copy authoring reference only — do NOT include them in the `TickerLine` wire struct. The `text` field is what crosses the boundary; `dual_lens` is authoring metadata.
---
### #595 — Opening monologue trigger
**What exists:** `server/src/simulation/monologue.rs` — `MonologueState` has `pub character: String` (initialized as `"detective"`) and `pub enter_location_fired: bool`. The system `tick_monologue()` calls `select_monologue_line()` which filters pools by `pool.character != character` — so the character string gate already works. Opening monologue content is fully authored at:
- `content/campaigns/main/.../monologue/detective/opening.yaml` (12 lines, Sprint 12 #300)
- `content/campaigns/main/.../monologue/smuggler/opening.yaml` (Sprint 12 #299)
Both files use `trigger: enter_location` and `situation: [arrival, shift_start]`. The `enter_location` trigger fires on the first tick (`MonologueState.enter_location_fired = false` → fires → sets `true`).
**What to deliver:** This ticket is small because the infrastructure is almost complete. The only missing piece is that `MonologueState.character` is initialized as `"detective"` before the archetype is known. Fix:
1. After #587 lands (archetype inserted on player entity), read `CharacterArchetype` in `MonologuePlugin.build()` or the first-tick setup system and set `MonologueState.character` correctly: `CharacterArchetype::Smuggler → "smuggler"`, `CharacterArchetype::Detective → "detective"`.
2. Verify that `select_monologue_line()` with `trigger = "enter_location"` and `character = "smuggler"` correctly selects from `opening.yaml` in the smuggler subdirectory. Run the existing monologue integration test with a smuggler archetype — it should already pass once #587 sets the character string.
3. Add a regression test: spawn two sessions (smuggler + detective), advance 1 tick each, assert different `character` on `MonologueState`. This verifies the archetype flows end-to-end.
**Blocked by:** #587 (needs `CharacterArchetype` on player entity before this system can read it).
---
### #594 — Tile data model design (D-record)
**What exists:** Current tile format is single characters (`F/W/V/R`) in string arrays in location YAML files. This cannot represent per-tile properties (door access lists, container contents, damage state, visual variant, sound properties, trigger zones). Epic #586 tracks this. This ticket produces the design only — no migration, no implementation.
**What to deliver:** A filed D-record in `decisions/architecture.md` via `tooling/db/decision claim D architecture "Tile data model — extensible per-tile properties"` before writing. The D-record must:
1. Survey what tile-level data the game needs across systems (doors, containers, damage, visual variants, trigger zones, material properties).
2. Choose between: (a) tile palette/registry (tiles are typed by ID, properties on the type), (b) per-tile property bags (each tile can have arbitrary key-value), (c) ECS-style tile components (tiles are entities), or (d) hybrid.
3. Specify the YAML authoring format (human-writable, survives merge conflicts), the loader contract (how the server parses it), and the runtime representation (what ECS queries use).
4. Estimate migration effort for the 5 existing location YAMLs.
**Scope:** This is design work only. No code changes. The D-record is the deliverable. Tyre should author it; Dudley reviews for implementation feasibility. File via the standard decision workflow (`decision claim` → edit `decisions/architecture.md` → commit with pre-commit hook running `decisions-sync`).
---
### #593 — v0.1 playthrough proof
**What exists:** By the time this ticket starts, all upstream tickets are merged: archetype in StartupMessage (#587), tell escalation (#589), ticker in snapshot (#591), opening monologue archetype-gated (#595), client character select (#588), client triangle consumer (#590), client ticker (#592), copy activation monologue lines (#597).
**What to deliver:** This is an integration proof ticket, not an implementation ticket. Deliverable is a written test plan execution + green CI.
1. **Full playthrough test (manual):** Boot server in production mode (no `--gauntlet`). Boot client in production mode (no `SR_TEST=1`). From main menu, click "New Game." Character select screen appears — select Smuggler. Game scene loads. Confirm: opening monologue fires (Smuggler voice). Walk to The Terminal. Observe Kael for ~3 game-minutes (check `EngagementRecord` via debug console `npc <id>`). Use debug console `contaminate` to skip to contamination phase. Wait for activation pass — `triangles` command shows one triangle in Active phase. Walk back to Kael — `tell_state` is `RoutineDeviation`. Proximity monologue fires (from copy #597). Walk to The Last Shift — news ticker visible. CI green.
2. **Automated integration test (server):** Add `test_v0_1_integration_playthrough` in `server/tests/`. Uses the existing test-client infrastructure (tooling/test-client): boot server, send `StartupMessage { world_seed: 12345, character_archetype: Smuggler }`, advance 1 tick, assert snapshot contains smuggler opening monologue, advance to tick 3000, send `SkipToContamination` debug command, advance 10 more ticks, assert `TriangleActivatedQueue` is non-empty in server state (via golden snapshot comparison).
3. **PR merge coordination:** Server #593 and client #588/#590/#592 must all be green on their respective branches before this ticket can be filed as done. The playthrough proof is the collective gate for the sprint.
**Gotcha — PROTOCOL_VERSION:** #587 bumps to 19. Client #588 must update `Protocol.PROTOCOL_VERSION` to 19 simultaneously. The version mismatch is a connection crash. Coordinate with Stig on merge timing.
---
## Dependency Chain
```
#587 (archetype in StartupMessage) ─────────────────────────────────────────────────┐
└→ #595 (opening monologue archetype-gated) │
└→ #588 (client: character select screen) ← client work │
↓
#589 (tell escalation on TriangleActivated) ─────────────────────────────────────── #593
└→ #590 (client: triangle consumer) ← client work (v0.1
playthrough
#591 (news ticker in snapshot) ─────────────────────────────────────────────────── proof)
└→ #592 (client: ticker HUD) ← client work │
│
#597 (copy: activation monologue lines) ← copy work ────────┘
#594 (tile data model D-record) ← standalone design track, no v0.1 dependency
```
Parallel server tracks: #587, #589, #591, #594 are all independent — start all simultaneously. #595 blocked on #587.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(server): character archetype in StartupMessage and monologue gating" \
--description "body" --base main --head server
```
---
## Sprint Completion (Server Criteria)
1. `cargo test` green on server branch. All existing tests pass. New tests for archetype round-trip and tell escalation pass.
2. `StartupMessage` with `character_archetype: Smuggler` produces a smuggler player entity with `MonologueState.character = "smuggler"` on tick 1.
3. After `TriangleActivated` fires, triangle NPCs have `RoutineDeviation` inserted and `tell_state: RoutineDeviation` appears in the snapshot.
4. Snapshot contains `current_ticker` (non-null headline) when player is in `"bar"` zone.
5. Opening monologue (enter_location) fires on tick 1 with lines from the correct character pool.
6. Integration test `test_v0_1_integration_playthrough` passes end-to-end.
7. `PROTOCOL_VERSION = 19` — matches client.
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.22
version: 0.1.23
repository: settled-reach
codename: commonwealth
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.20"
version = "0.1.22"
dependencies = [
"bevy_app",
"bevy_ecs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.22"
version = "0.1.23"
edition = "2021"
[dependencies]
+505
View File
@@ -0,0 +1,505 @@
//! Debug console command handler (#580).
//!
//! Processes `DebugCommandKind` variants buffered by `process_player_input`
//! and writes `DebugResponsePayload` to `SnapshotBuffer.pending_debug_response`.
//!
//! Security: only executes when `DebugEnabled` resource is true.
//! v0.1: enabled by default. Cannot be toggled mid-session.
use bevy_ecs::prelude::*;
use crate::bridge::types::{
DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer,
};
use crate::content::template::TriangleState;
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use crate::storyteller::{ContaminationActive, ContaminationEventQueue, CONTAMINATION_DELAY_TICKS};
// ---------------------------------------------------------------------------
// Buffer resource
// ---------------------------------------------------------------------------
/// Buffer for debug commands forwarded from `process_player_input`.
///
/// Drained by `handle_debug_commands` each tick. Only the last command's
/// response is delivered (debug console is request-response, not batched).
#[derive(Resource, Default, Debug)]
pub struct DebugCommandBuffer {
commands: Vec<DebugCommandKind>,
}
impl DebugCommandBuffer {
pub fn push(&mut self, cmd: DebugCommandKind) {
self.commands.push(cmd);
}
pub fn drain(&mut self) -> Vec<DebugCommandKind> {
std::mem::take(&mut self.commands)
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Processes buffered debug commands and writes responses to the snapshot buffer.
///
/// Runs after `process_player_input`, before `compute_observer_snapshot`.
/// Gated by `DebugEnabled` — if false, all commands are silently dropped.
#[allow(clippy::too_many_arguments)]
pub fn handle_debug_commands(
debug_enabled: Option<Res<DebugEnabled>>,
mut cmd_buffer: ResMut<DebugCommandBuffer>,
mut buffer: ResMut<SnapshotBuffer>,
mut time: ResMut<SimulationTime>,
mut contamination: Option<ResMut<ContaminationActive>>,
mut contamination_queue: Option<ResMut<ContaminationEventQueue>>,
walkability: Option<Res<WalkabilityMap>>,
registry: Res<EntityRegistry>,
mut player_query: Query<(Entity, &mut TilePosition), With<PlayerCharacter>>,
triangles: Query<(Entity, &TriangleState), With<ActiveSim>>,
npcs: Query<(Entity, &TilePosition, Option<&NpcName>), (With<Npc>, With<ActiveSim>, Without<PlayerCharacter>)>,
) {
// Gate: debug must be enabled
let enabled = debug_enabled.as_ref().map_or(false, |d| d.0);
let commands = cmd_buffer.drain();
if commands.is_empty() {
return;
}
// Process each command; last response wins (single debug_response per tick)
for cmd in commands {
let response = if !enabled {
DebugResponsePayload {
command: format!("{:?}", cmd),
text: "Debug console is disabled.".to_string(),
success: false,
}
} else {
match cmd {
DebugCommandKind::AdvanceTicks(n) => {
let old_tick = time.tick;
time.tick = time.tick.saturating_add(n);
DebugResponsePayload {
command: format!("AdvanceTicks({})", n),
text: format!(
"Advanced {} ticks: {} -> {}",
n, old_tick, time.tick
),
success: true,
}
}
DebugCommandKind::SkipToContamination => {
let is_active = contamination.as_ref().map(|c| c.0);
match is_active {
None => DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
},
Some(true) => DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Contamination already active (fired at or before tick {}). Current tick: {}",
time.tick, time.tick
),
success: true,
},
Some(false) if time.tick >= CONTAMINATION_DELAY_TICKS => {
// Tick is already past the delay — advancing would be a no-op
// or rewinding would break cooldowns. Report error instead.
DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Current tick ({}) already past contamination delay ({}). Contamination should fire on next system run — use ForceContaminationActivate if it hasn't.",
time.tick, CONTAMINATION_DELAY_TICKS
),
success: false,
}
}
Some(false) => {
let old_tick = time.tick;
time.tick = CONTAMINATION_DELAY_TICKS;
DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.",
old_tick, time.tick
),
success: true,
}
}
}
}
DebugCommandKind::TeleportToPosition { x, y, z } => {
let target = TilePosition::new(x, y, z);
// Validate target is walkable (if WalkabilityMap available)
let walkable = walkability
.as_ref()
.map_or(true, |wm| wm.can_move_to(&target));
if !walkable {
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: format!(
"Target ({}, {}, {}) is not walkable. Player would be stuck in wall/void.",
x, y, z
),
success: false,
}
} else if let Ok((_, mut pos)) = player_query.single_mut() {
let old = (pos.x, pos.y, pos.z);
pos.x = x;
pos.y = y;
pos.z = z;
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: format!(
"Teleported player: ({}, {}, {}) -> ({}, {}, {})",
old.0, old.1, old.2, x, y, z
),
success: true,
}
} else {
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: "No player entity found.".to_string(),
success: false,
}
}
}
DebugCommandKind::TeleportToLocation(ref name) => {
// Location-based teleport requires ContentStore (future: resolve location
// center from tile_bounds). For now, report unimplemented.
DebugResponsePayload {
command: format!("TeleportToLocation({})", name),
text: format!(
"TeleportToLocation not yet implemented (needs location tile_bounds from ContentStore). Use TeleportToPosition instead."
),
success: false,
}
}
DebugCommandKind::ForceContaminationActivate => {
if contamination.is_none() {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
}
} else if contamination.as_ref().unwrap().0 {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination already active.".to_string(),
success: true,
}
} else {
contamination.as_mut().unwrap().0 = true;
// Also push an event so downstream systems react
if let Some(ref mut queue) = contamination_queue {
queue.push(crate::storyteller::ContaminationEvent {
tick: time.tick,
triangles_affected: 0, // no pressure delta applied — use SkipToContamination for that
});
}
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: format!(
"Contamination force-activated at tick {}. Note: no tension delta applied (use SkipToContamination for full effect).",
time.tick
),
success: true,
}
}
}
DebugCommandKind::ForceTriangleActivation(ref slug) => {
// Future: match triangle by slug and force-activate.
// Requires TriangleId slug lookup which isn't indexed yet.
DebugResponsePayload {
command: format!("ForceTriangleActivation({})", slug),
text: "ForceTriangleActivation not yet implemented (needs TriangleId slug index).".to_string(),
success: false,
}
}
DebugCommandKind::InspectNpc(wire_id) => {
let stable_id = crate::knowledge::types::StableId(wire_id);
if let Some(entity) = registry.to_entity(&stable_id) {
if let Ok((_, pos, name)) = npcs.get(entity) {
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!(
"NPC {} (stable_id={})\n Position: ({}, {}, {})\n Name: {}",
entity, wire_id, pos.x, pos.y, pos.z, name_str
),
success: true,
}
} else {
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!("Entity {} exists but is not an Active-tier NPC.", wire_id),
success: false,
}
}
} else {
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!("No entity found for stable_id {}.", wire_id),
success: false,
}
}
}
DebugCommandKind::ListTriangles => {
let mut lines = Vec::new();
lines.push("=== Triangles ===".to_string());
let mut count = 0u32;
for (entity, state) in &triangles {
count += 1;
lines.push(format!(
" {} | id={} | phase={:?} | tension={} | class={:?} | template={}",
entity,
state.triangle_id.0,
state.phase,
state.tension,
state.classification,
state.template_id.0,
));
}
lines.push(format!("Total: {}", count));
DebugResponsePayload {
command: "ListTriangles".to_string(),
text: lines.join("\n"),
success: true,
}
}
DebugCommandKind::ListPopulation => {
let mut lines = Vec::new();
lines.push("=== Active-Tier NPCs ===".to_string());
let mut count = 0u32;
for (entity, pos, name) in &npcs {
count += 1;
let stable = registry.to_stable(entity);
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
lines.push(format!(
" {} | sid={} | pos=({},{},{}) | {}",
entity,
stable.map(|s| s.0.to_string()).unwrap_or_else(|| "?".to_string()),
pos.x, pos.y, pos.z,
name_str,
));
}
lines.push(format!("Total: {}", count));
DebugResponsePayload {
command: "ListPopulation".to_string(),
text: lines.join("\n"),
success: true,
}
}
DebugCommandKind::GetContaminationStatus => {
if let Some(ref cont) = contamination {
DebugResponsePayload {
command: "GetContaminationStatus".to_string(),
text: format!(
"Contamination active: {}\nCurrent tick: {}\nContamination delay: {} ticks",
cont.0, time.tick, CONTAMINATION_DELAY_TICKS
),
success: true,
}
} else {
DebugResponsePayload {
command: "GetContaminationStatus".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
}
}
}
}
};
tracing::debug!(command = %response.command, success = response.success, "Debug command processed");
if buffer.pending_debug_response.is_some() {
tracing::trace!(
"Debug response overwritten by '{}' — earlier response dropped (last-wins per tick)",
response.command
);
}
buffer.pending_debug_response = Some(response);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
use bevy_ecs::schedule::Schedule;
fn setup_debug_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<DebugCommandBuffer>();
world.init_resource::<SnapshotBuffer>();
world.init_resource::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<EntityRegistry>();
world.insert_resource(DebugEnabled(true));
// Spawn a player entity
world.spawn((PlayerCharacter, TilePosition::new(10, 20, 0)));
let mut schedule = Schedule::default();
schedule.add_systems(handle_debug_commands);
(world, schedule)
}
#[test]
fn advance_ticks_updates_simulation_time() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 100;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::AdvanceTicks(50));
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 150);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
assert!(resp.text.contains("150"));
}
#[test]
fn skip_to_contamination_sets_tick() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 10;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, CONTAMINATION_DELAY_TICKS);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn teleport_moves_player() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<DebugCommandBuffer>().push(
DebugCommandKind::TeleportToPosition { x: 50, y: 60, z: 1 },
);
schedule.run(&mut world);
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
let pos = q.single(&world).unwrap();
assert_eq!((pos.x, pos.y, pos.z), (50, 60, 1));
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn force_contamination_activates() {
let (mut world, mut schedule) = setup_debug_world();
assert!(!world.resource::<ContaminationActive>().0);
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::ForceContaminationActivate);
schedule.run(&mut world);
assert!(world.resource::<ContaminationActive>().0);
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn debug_disabled_rejects_commands() {
let (mut world, mut schedule) = setup_debug_world();
world.insert_resource(DebugEnabled(false));
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
schedule.run(&mut world);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(!resp.success);
assert!(resp.text.contains("disabled"));
}
#[test]
fn get_contamination_status_reports_state() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 42;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
schedule.run(&mut world);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
assert!(resp.text.contains("false")); // not yet active
assert!(resp.text.contains("42")); // current tick
}
#[test]
fn no_commands_produces_no_response() {
let (mut world, mut schedule) = setup_debug_world();
schedule.run(&mut world);
assert!(world.resource::<SnapshotBuffer>().pending_debug_response.is_none());
}
#[test]
fn teleport_rejects_unwalkable_target() {
let (mut world, mut schedule) = setup_debug_world();
let mut map = WalkabilityMap::new(10, 10, 1);
map.set_walkable(&TilePosition::new(5, 5, 0), false);
world.insert_resource(map);
world.resource_mut::<DebugCommandBuffer>().push(
DebugCommandKind::TeleportToPosition { x: 5, y: 5, z: 0 },
);
schedule.run(&mut world);
// Player should NOT have moved
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
let pos = q.single(&world).unwrap();
assert_eq!((pos.x, pos.y, pos.z), (10, 20, 0), "player must not teleport to unwalkable tile");
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(!resp.success);
assert!(resp.text.contains("not walkable"));
}
#[test]
fn skip_to_contamination_rejects_when_past_delay() {
let (mut world, mut schedule) = setup_debug_world();
// Set tick past the delay but contamination not yet active
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
schedule.run(&mut world);
// Tick should NOT have changed (no rewind)
assert_eq!(
world.resource::<SimulationTime>().tick,
CONTAMINATION_DELAY_TICKS + 100,
"tick must not rewind"
);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(!resp.success);
assert!(resp.text.contains("already past"));
}
#[test]
fn debug_enabled_defaults_to_debug_assertions() {
let d = DebugEnabled::default();
assert_eq!(d.0, cfg!(debug_assertions));
}
}
+6
View File
@@ -6,6 +6,7 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod debug;
pub mod framing;
pub mod local;
pub mod tcp;
@@ -221,12 +222,17 @@ impl Plugin for BridgePlugin {
.init_resource::<ServerRunning>()
.init_resource::<HandshakeState>()
.init_resource::<SimErrorBuffer>()
.init_resource::<debug::DebugCommandBuffer>()
.init_resource::<DebugEnabled>()
.init_resource::<crate::perception::query::VisibilityGeometry>()
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.add_systems(
Update,
(
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
debug::handle_debug_commands
.after(crate::simulation::input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
crate::perception::observer::compute_visibility_geometry
.after(crate::simulation::movement::validate_movement),
crate::simulation::interaction::compute_nearby_interactions
+3
View File
@@ -192,6 +192,7 @@ fn sector_label(sector: VisibilitySector) -> &'static str {
match sector {
VisibilitySector::Forward => "Forward",
VisibilitySector::Peripheral => "Periph",
VisibilitySector::BoundaryWall => "BndWall",
}
}
@@ -316,6 +317,7 @@ mod tests {
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
debug_response: None,
}
}
@@ -454,6 +456,7 @@ mod tests {
triangle_crisis_events: vec![],
state_hash: None,
sim_errors: vec![],
debug_response: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+73 -2
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 17;
pub const PROTOCOL_VERSION: u8 = 18;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -72,10 +72,11 @@ pub struct StartupMessage {
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
/// sim_errors (#85, structured error reporting to client).
/// v18 adds: debug_response (#580, debug console server — command/response wire).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 17.
/// Protocol version for forward compatibility. Current: 18.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -191,6 +192,11 @@ pub struct ObserverSnapshot {
/// Empty in normal operation. Client may display a warning toast.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sim_errors: Vec<SimError>,
/// Debug console response for this tick (#580).
/// Present when a debug command was processed. Client renders in
/// the tilde console overlay. None in normal gameplay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug_response: Option<DebugResponsePayload>,
}
/// Game time data for client display (D-031)
@@ -336,6 +342,10 @@ pub enum VisibilitySector {
Forward,
/// Reduced range, dimmer rendering (side arcs)
Peripheral,
/// Wall tile 1 beyond the LOS boundary (#584).
/// Gives the client wall data at the fog edge so fog composites over
/// real geometry rather than empty space. Not stored in exploration/memory.
BoundaryWall,
}
/// A visible entity in the simulation
@@ -473,6 +483,9 @@ pub enum PlayerAction {
/// Client sends this when the player selects a save file to load.
/// Server executes load_from_file and sends SaveLoadResultWire confirmation.
LoadGame { path: String },
/// Debug console command (#580). Only processed when `DebugEnabled` is true.
/// Response delivered via `ObserverSnapshot.debug_response`.
DebugCommand(DebugCommandKind),
}
impl PlayerAction {
@@ -492,6 +505,62 @@ impl PlayerAction {
}
}
/// Debug command variants for the in-game console (#580).
///
/// Sent via `PlayerAction::DebugCommand`. Only processed when `DebugEnabled`
/// resource is true. Response returned in `ObserverSnapshot.debug_response`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DebugCommandKind {
/// Fast-forward simulation by N ticks.
AdvanceTicks(u64),
/// Advance simulation to `CONTAMINATION_DELAY_TICKS` (skip early game).
SkipToContamination,
/// Move player to absolute tile position.
TeleportToPosition { x: i32, y: i32, z: i32 },
/// Move player to a named location's origin (e.g. "the-terminal").
TeleportToLocation(String),
/// Bypass contamination timer — fire `ContaminationActive` immediately.
ForceContaminationActivate,
/// Force-activate a triangle by its string slug identifier.
ForceTriangleActivation(String),
/// Dump NPC state: current routine, knowledge graph summary, relationships.
InspectNpc(u64),
/// Return all `TriangleState` entities with phase/tension/classification.
ListTriangles,
/// Return all Active-tier NPCs with tier, position, and name.
ListPopulation,
/// Return `ContaminationActive` status and current tick.
GetContaminationStatus,
}
/// Debug response payload included in `ObserverSnapshot` (#580).
///
/// Carries the result of a debug command as a human-readable text block
/// plus structured data where useful. The client's debug console displays
/// the `text` field directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebugResponsePayload {
/// The command that produced this response (for client-side echo).
pub command: String,
/// Human-readable response text (multi-line, displayed in console).
pub text: String,
/// Whether the command succeeded.
pub success: bool,
}
/// Whether the debug console is enabled (#580).
///
/// Set at server startup. Cannot be toggled mid-session via IPC.
/// Defaults to true in debug builds, false in release builds.
#[derive(Resource, Debug, Clone)]
pub struct DebugEnabled(pub bool);
impl Default for DebugEnabled {
fn default() -> Self {
Self(cfg!(debug_assertions))
}
}
/// Available interaction verbs for a nearby entity (D-060, #404)
/// Embedded in ObserverSnapshot.nearby_interactions.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -804,6 +873,8 @@ pub struct SnapshotBuffer {
pub snapshot: Option<ObserverSnapshot>,
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
pub pending_save_result: Option<SaveLoadResultWire>,
/// Pending debug response, consumed once by `compute_observer_snapshot` (#580).
pub pending_debug_response: Option<DebugResponsePayload>,
}
#[cfg(test)]
+314
View File
@@ -327,6 +327,131 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
}
/// Check if a YAML file contains only comments and whitespace (stub file).
// ---------------------------------------------------------------------------
// Tile loading (#577)
// ---------------------------------------------------------------------------
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
/// Parse a tile character into a TileKind.
/// Returns `None` for unrecognized characters.
fn parse_tile_char(ch: char) -> Option<TileKind> {
match ch {
'F' => Some(TileKind::Floor),
'W' => Some(TileKind::Wall),
'V' => Some(TileKind::Void),
'R' => Some(TileKind::Restricted),
_ => None,
}
}
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
///
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
///
/// Logs warnings for:
/// - Row count mismatch vs tile_bounds height
/// - Column count mismatch vs tile_bounds width
/// - Unrecognized tile characters
///
/// Returns the number of locations that had tile data applied.
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
let mut locations_loaded = 0u32;
for (_district_id, district) in &store.districts {
for location in &district.locations {
if apply_location_tiles(location, walkability) {
locations_loaded += 1;
}
}
}
locations_loaded
}
/// Apply tile data from a single Location to the WalkabilityMap.
/// Returns true if tiles were applied, false if skipped.
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
return false;
};
// Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to
// ~18 quintillion when cast to usize, silently writing tiles at garbage positions.
if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max {
tracing::error!(
"Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping",
location.canonical_id,
bounds.x_min, bounds.x_max,
bounds.y_min, bounds.y_max,
);
return false;
}
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
if tiles.len() != expected_height {
tracing::error!(
"Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes",
location.canonical_id,
tiles.len(),
expected_height,
);
return false;
}
for (row_idx, row) in tiles.iter().enumerate() {
let y = bounds.y_min + row_idx as i32;
if row.len() != expected_width {
tracing::error!(
"Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes",
location.canonical_id,
row_idx,
row.len(),
expected_width,
);
continue;
}
for (col_idx, ch) in row.chars().enumerate() {
let x = bounds.x_min + col_idx as i32;
let pos = TilePosition::new(x, y, bounds.z);
match parse_tile_char(ch) {
Some(kind) => {
let walkable = matches!(kind, TileKind::Floor);
walkability.set_walkable(&pos, walkable);
walkability.set_tile_kind(&pos, kind);
}
None => {
tracing::warn!(
"Location '{}' row {} col {}: unrecognized tile char '{}'",
location.canonical_id,
row_idx,
col_idx,
ch,
);
}
}
}
}
tracing::info!(
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
location.canonical_id,
expected_width,
expected_height,
bounds.x_min,
bounds.y_min,
bounds.z,
);
true
}
fn is_comment_only_file(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
@@ -618,4 +743,193 @@ pools:
}
}
}
// -----------------------------------------------------------------------
// Tile loading tests (#577)
// -----------------------------------------------------------------------
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
Location {
canonical_id: "test-loc".to_string(),
display_name: "Test Location".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 0,
y_min: 0,
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
y_max: tiles.len() as i32 - 1,
z: 0,
}),
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
sightlines: None,
ambient_sound: None,
social_site: None,
}
}
#[test]
fn parse_tile_char_all_kinds() {
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
assert_eq!(parse_tile_char('X'), None);
assert_eq!(parse_tile_char(' '), None);
}
#[test]
fn apply_location_tiles_stamps_walkability() {
let loc = make_location_with_tiles(vec![
"FWF",
"FFF",
"WFW",
]);
let mut map = WalkabilityMap::new(4, 4, 1);
let applied = apply_location_tiles(&loc, &mut map);
assert!(applied);
// Row 0: F W F
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
// Row 1: F F F
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
// Row 2: W F W
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
}
#[test]
fn apply_location_tiles_stamps_tile_kind() {
let loc = make_location_with_tiles(vec![
"FWVR",
]);
let mut map = WalkabilityMap::new(4, 1, 1);
apply_location_tiles(&loc, &mut map);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
}
#[test]
fn apply_location_tiles_with_offset() {
let loc = Location {
canonical_id: "offset-loc".to_string(),
display_name: "Offset".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 10,
y_min: 20,
x_max: 12,
y_max: 21,
z: 0,
}),
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(32, 32, 1);
apply_location_tiles(&loc, &mut map);
// (10,20) = F, (11,20) = W, (12,20) = F
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
// (10,21) = W, (11,21) = F, (12,21) = W
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
}
#[test]
fn apply_location_tiles_skips_without_tiles() {
let loc = Location {
canonical_id: "no-tiles".to_string(),
display_name: "No Tiles".to_string(),
description: None,
tile_bounds: Some(TileBounds {
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
}),
tiles: None,
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(5, 5, 1);
assert!(!apply_location_tiles(&loc, &mut map));
}
#[test]
fn apply_location_tiles_skips_without_bounds() {
let loc = Location {
canonical_id: "no-bounds".to_string(),
display_name: "No Bounds".to_string(),
description: None,
tile_bounds: None,
tiles: Some(vec!["FFF".to_string()]),
sightlines: None,
ambient_sound: None,
social_site: None,
};
let mut map = WalkabilityMap::new(5, 5, 1);
assert!(!apply_location_tiles(&loc, &mut map));
}
#[test]
fn load_location_tiles_from_store() {
let mut store = ContentStore::default();
let mut district = DistrictContent::default();
district.locations.push(make_location_with_tiles(vec![
"FW",
"WF",
]));
store.districts.insert("test".to_string(), district);
let mut map = WalkabilityMap::new(4, 4, 1);
let count = load_location_tiles(&store, &mut map);
assert_eq!(count, 1);
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(0, 1, 0)));
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
}
#[test]
fn location_yaml_with_tiles_deserializes() {
let yaml = r#"
canonical_id: test-room
display_name: "Test Room"
tile_bounds:
x_min: 5
y_min: 10
x_max: 9
y_max: 12
z: 0
tiles:
- "FFFFF"
- "FWWWF"
- "FFFFF"
"#;
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
assert_eq!(loc.canonical_id, "test-room");
assert!(loc.tiles.is_some());
let tiles = loc.tiles.unwrap();
assert_eq!(tiles.len(), 3);
assert_eq!(tiles[0], "FFFFF");
assert_eq!(tiles[1], "FWWWF");
assert_eq!(tiles[2], "FFFFF");
}
}
+9
View File
@@ -79,6 +79,15 @@ fn load_and_spawn_content(world: &mut World) {
let result = spawn::spawn_content(world, &store);
tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned);
// Stamp location tile data onto WalkabilityMap (#577)
if world.contains_resource::<crate::simulation::movement::WalkabilityMap>() {
let mut walkability = world.resource_mut::<crate::simulation::movement::WalkabilityMap>();
let tiles_loaded = loader::load_location_tiles(&store, &mut walkability);
if tiles_loaded > 0 {
tracing::info!("Loaded tile data for {} locations", tiles_loaded);
}
}
// Build line pool index
let index = line_pool::LinePoolIndex::build(&store);
tracing::info!(
+4
View File
@@ -33,6 +33,7 @@ use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
use crate::storyteller::EngagementRecord;
// #166 — Template-to-instance mapping
use rand::Rng as _;
@@ -133,6 +134,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
entity_commands.insert(npc::mood::MoodState::default());
// Engagement metrics — storyteller activation scoring (#570, #162)
entity_commands.insert(EngagementRecord::default());
// Axis 1: Want
if let Some(want) = &profile.want {
if let Some(kind) = parse_want_kind(&want.primary) {
+15
View File
@@ -42,6 +42,7 @@ pub struct Discovery {
#[derive(Debug, Deserialize)]
pub struct DistrictMeta {
pub display_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub locations: Vec<String>,
@@ -394,6 +395,20 @@ pub struct Location {
pub description: Option<String>,
#[serde(default)]
pub tile_bounds: Option<TileBounds>,
/// Tile layout for this location (#577).
///
/// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y.
/// Each character maps to a server-side TileKind:
/// `F` = Floor (walkable, open space)
/// `W` = Wall (solid obstacle, blocks movement and LOS)
/// `V` = Void (out-of-bounds / unloaded)
/// `R` = Restricted (blocked but traversable by specific entities)
///
/// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`.
/// Requires `tile_bounds` to be set. Row count must equal
/// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`.
#[serde(default)]
pub tiles: Option<Vec<String>>,
#[serde(default)]
pub sightlines: Option<Sightlines>,
#[serde(default)]
+1
View File
@@ -300,6 +300,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
+7
View File
@@ -12,6 +12,7 @@ use crate::knowledge::{
use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Emit knowledge events for entities entering/leaving the observer's LOS.
///
@@ -29,6 +30,7 @@ pub fn emit_observation_events(
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
mut engagement_query: Query<&mut EngagementRecord>,
) {
let Some(snapshot) = &buffer.snapshot else {
return;
@@ -61,6 +63,11 @@ pub fn emit_observation_events(
continue;
};
// Increment observation time for engagement tracking (#570)
if let Ok(mut record) = engagement_query.get_mut(entity) {
record.observation_time_ticks += 1;
}
// Get tile position for knowledge tracking
let Ok(pos) = entity_positions.get(entity) else {
continue;
+4
View File
@@ -392,6 +392,9 @@ pub fn compute_observer_snapshot(
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
// Consume pending debug response for this tick (#580).
let debug_response = buffer.pending_debug_response.take();
// Drain triangle crisis events (#250) and convert to wire format.
// Drain triangle crisis events (#250) and filter role_assignments against
// observer KG (D-010 principle 2: information boundaries are universal).
@@ -464,6 +467,7 @@ pub fn compute_observer_snapshot(
triangle_crisis_events,
state_hash,
sim_errors,
debug_response,
});
}
+148
View File
@@ -96,6 +96,23 @@ impl PerceptionQuery for NaturalVision {
.map(|&(x, y, sector)| ((x, y), sector))
.collect();
// --- Boundary wall margin pass (#584) ---
// Walk the LOS boundary and include non-walkable tiles 1 tile beyond.
// This gives the client wall geometry at the fog edge.
let boundary_walls = compute_boundary_walls(&visible_positions, walkability, z);
for (bx, by) in &boundary_walls {
visible_tiles.push(VisibleTile {
x: *bx,
y: *by,
z,
visibility: VisibilitySector::BoundaryWall,
tile_kind: TileKind::Wall,
zone_id: None,
});
}
// Re-sort after adding boundary walls
visible_tiles.sort_by_key(|t| (t.x, t.y));
VisibilityGeometry {
visible_tiles,
visible_positions,
@@ -105,6 +122,37 @@ impl PerceptionQuery for NaturalVision {
}
}
/// Compute wall tiles 1 tile beyond the LOS boundary (#584).
///
/// For each tile on the boundary of the visible set (has at least one
/// 4-neighbor outside the set), check each non-visible neighbor.
/// If that neighbor is not walkable, include it as a boundary wall.
///
/// Returns deduplicated (x, y) positions of wall tiles to add.
fn compute_boundary_walls(
visible_positions: &BTreeSet<(i32, i32)>,
walkability: &WalkabilityMap,
z: i32,
) -> Vec<(i32, i32)> {
const NEIGHBORS: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
let mut walls = BTreeSet::new();
for &(x, y) in visible_positions {
for (dx, dy) in NEIGHBORS {
let nx = x + dx;
let ny = y + dy;
if !visible_positions.contains(&(nx, ny)) {
let pos = TilePosition::new(nx, ny, z);
if !walkability.can_move_to(&pos) {
walls.insert((nx, ny));
}
}
}
}
walls.into_iter().collect()
}
/// Resource wrapping the active perception mode (D-017).
/// Defaults to NaturalVision. Swap this resource to change perception modes.
#[derive(Resource)]
@@ -115,3 +163,103 @@ impl Default for ActivePerceptionMode {
Self(Box::new(NaturalVision))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a small walkability map with walls around the edges.
/// Layout (5x5, z=0):
/// W W W W W
/// W F F F W
/// W F F F W
/// W F F F W
/// W W W W W
fn make_walled_map() -> WalkabilityMap {
let mut map = WalkabilityMap::new(5, 5, 1);
// All tiles start walkable (floor). Set border to non-walkable (wall).
for x in 0..5 {
map.set_walkable(&TilePosition::new(x, 0, 0), false);
map.set_walkable(&TilePosition::new(x, 4, 0), false);
}
for y in 0..5 {
map.set_walkable(&TilePosition::new(0, y, 0), false);
map.set_walkable(&TilePosition::new(4, y, 0), false);
}
map
}
#[test]
fn boundary_walls_include_adjacent_walls() {
// Visible set: just the center tile (2,2)
let visible: BTreeSet<(i32, i32)> = [(2, 2)].into_iter().collect();
let map = make_walled_map();
let walls = compute_boundary_walls(&visible, &map, 0);
// All 4 neighbors of (2,2) are floor tiles (walkable), so no walls.
// This verifies we don't add walkable tiles as boundary walls.
assert!(walls.is_empty());
}
#[test]
fn boundary_walls_found_at_edge() {
// Visible set: tiles along the north interior edge (y=1)
let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1), (3, 1)].into_iter().collect();
let map = make_walled_map();
let walls = compute_boundary_walls(&visible, &map, 0);
// North neighbors (y=0) are all walls: (1,0), (2,0), (3,0)
// Also (0,1) is a wall (west of (1,1)) and (4,1) (east of (3,1))
assert!(walls.contains(&(1, 0)));
assert!(walls.contains(&(2, 0)));
assert!(walls.contains(&(3, 0)));
assert!(walls.contains(&(0, 1)));
assert!(walls.contains(&(4, 1)));
}
#[test]
fn boundary_walls_deduplicated() {
// Two adjacent visible tiles share a wall neighbor
let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1)].into_iter().collect();
let map = make_walled_map();
let walls = compute_boundary_walls(&visible, &map, 0);
// Count how many times (1,0) appears — should be exactly 1 (deduplicated)
let count = walls.iter().filter(|&&(x, y)| x == 1 && y == 0).count();
assert_eq!(count, 1, "boundary walls should be deduplicated");
}
#[test]
fn boundary_walls_not_in_visible_positions() {
// Verify the full NaturalVision pipeline produces BoundaryWall tiles
// that are NOT in visible_positions.
let map = make_walled_map();
let nv = NaturalVision;
let pos = TilePosition::new(2, 2, 0);
let geometry = nv.compute_geometry(&pos, FacingDirection::North, &map);
let boundary_tiles: Vec<_> = geometry
.visible_tiles
.iter()
.filter(|t| t.visibility == VisibilitySector::BoundaryWall)
.collect();
// There should be boundary wall tiles (the 5x5 map has walls at edges)
assert!(!boundary_tiles.is_empty(), "expected boundary wall tiles");
// None of the boundary wall tiles should be in visible_positions
for tile in &boundary_tiles {
assert!(
!geometry.visible_positions.contains(&(tile.x, tile.y)),
"BoundaryWall tile ({}, {}) should NOT be in visible_positions",
tile.x,
tile.y
);
}
// All boundary wall tiles should have TileKind::Wall
for tile in &boundary_tiles {
assert_eq!(tile.tile_kind, TileKind::Wall);
}
}
}
+337
View File
@@ -0,0 +1,337 @@
//! Chunk streaming system (#578, D-012).
//!
//! Loads chunks near the player and unloads distant chunks based on a
//! configurable radius. For v0.1 the radius covers the entire hand-authored
//! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks
//! remain loaded. The architecture supports future per-demand loading (v0.3+).
//!
//! The system runs on a configurable tick cadence (default: every 10 ticks).
//! It queries the player's TilePosition, computes which chunks should be
//! loaded (Chebyshev distance ≤ radius from the player's chunk), and
//! loads/unloads accordingly.
use bevy_ecs::prelude::*;
use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::time::SimulationTime;
/// How many chunks around the player to keep loaded (Chebyshev distance).
///
/// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks).
/// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming.
#[derive(Resource, Debug, Clone)]
pub struct ChunkLoadRadius {
pub radius: i32,
}
impl Default for ChunkLoadRadius {
fn default() -> Self {
Self { radius: 8 }
}
}
/// How often the streaming system runs, in simulation ticks.
///
/// Default: 10 ticks (one game-minute at D-031 cadence).
/// Lower values increase responsiveness but add per-tick overhead.
#[derive(Resource, Debug, Clone)]
pub struct ChunkStreamingCadence {
pub ticks: u64,
}
impl Default for ChunkStreamingCadence {
fn default() -> Self {
Self { ticks: 10 }
}
}
/// Chunk streaming system — loads/unloads chunks around the player position.
///
/// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks).
/// Computes the set of chunks within `ChunkLoadRadius` of the player's
/// current chunk (Chebyshev distance), loads missing chunks, and unloads
/// chunks that are now out of range.
///
/// For v0.1, radius=8 covers the entire district so nothing ever unloads.
/// For v0.3+, the generator fills newly loaded chunks with terrain data.
pub fn chunk_streaming(
time: Res<SimulationTime>,
cadence: Res<ChunkStreamingCadence>,
radius: Res<ChunkLoadRadius>,
walkability: Option<ResMut<WalkabilityMap>>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
) {
let Some(mut walkability) = walkability else {
return;
};
// Cadence gate — only run every N ticks
if cadence.ticks > 0 && time.tick % cadence.ticks != 0 {
return;
}
let Ok(player_pos) = player_query.single() else {
return;
};
let player_chunk = player_pos.chunk_coord();
let r = radius.radius;
// Load chunks within radius that aren't already loaded
let mut loaded = 0u32;
for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) {
for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) {
let coord = ChunkCoord {
cx,
cy,
z: player_chunk.z,
};
if walkability.load_chunk(coord) {
loaded += 1;
}
}
}
// Unload chunks outside radius
let mut unloaded = 0u32;
let to_check = walkability.loaded_chunk_coords();
for coord in to_check {
// Only manage chunks on the player's z-level
if coord.z != player_chunk.z {
continue;
}
let dx = (coord.cx - player_chunk.cx).abs();
let dy = (coord.cy - player_chunk.cy).abs();
if dx > r || dy > r {
walkability.unload_chunk(&coord);
unloaded += 1;
}
}
if loaded > 0 || unloaded > 0 {
tracing::debug!(
"Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})",
loaded,
unloaded,
player_chunk.cx,
player_chunk.cy,
player_chunk.z,
r,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::schedule::Schedule;
fn setup_streaming_world(
radius: i32,
cadence: u64,
player_pos: TilePosition,
map_width: i32,
map_height: i32,
) -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(ChunkLoadRadius { radius });
world.insert_resource(ChunkStreamingCadence { ticks: cadence });
world.insert_resource(WalkabilityMap::new(map_width, map_height, 1));
world.spawn((PlayerCharacter, player_pos));
let mut schedule = Schedule::default();
schedule.add_systems(chunk_streaming);
(world, schedule)
}
#[test]
fn default_radius_covers_v01_district() {
// 256×256 visual district = 8×8 chunks of 32 tiles each.
// Player at center (128, 128). Default radius=8.
// All original 64 chunks should still be loaded after streaming runs
// (streaming may also create empty chunks beyond the district boundary).
let (mut world, mut schedule) = setup_streaming_world(
8,
1, // run every tick
TilePosition::new(128, 128, 0),
256,
256,
);
// Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 64);
schedule.run(&mut world);
// All original chunks (0..8, 0..8) must still be loaded — nothing unloaded
let wm = world.resource::<WalkabilityMap>();
for cx in 0..8 {
for cy in 0..8 {
assert!(
wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }),
"chunk ({},{}) should still be loaded",
cx,
cy,
);
}
}
// Total count ≥ 64 (streaming may also load chunks beyond district boundary)
assert!(wm.chunk_count() >= 64);
}
#[test]
fn small_radius_unloads_distant_chunks() {
// Start with a 5×5 chunk map (160×160 tiles), player at center.
// Use radius=1 so only 3×3=9 chunks around the player are kept.
let (mut world, mut schedule) = setup_streaming_world(
1,
1,
TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5
160,
160,
);
// Initial: 5×5 = 25 chunks
let initial_count = world.resource::<WalkabilityMap>().chunk_count();
assert_eq!(initial_count, 25);
schedule.run(&mut world);
// After streaming: only 3×3 = 9 chunks around player chunk (2,2)
let after_count = world.resource::<WalkabilityMap>().chunk_count();
assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1");
// Verify the player's chunk is still loaded
let wm = world.resource::<WalkabilityMap>();
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
// Corner chunks should be unloaded
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 }));
}
#[test]
fn player_movement_loads_new_chunks() {
// Start with radius=1, player at (16, 16) → chunk (0,0).
// Map is 3×3 chunks (96×96 tiles).
let (mut world, mut schedule) = setup_streaming_world(
1,
1,
TilePosition::new(16, 16, 0), // chunk (0,0)
96,
96,
);
// Run streaming — unloads distant chunks
schedule.run(&mut world);
// Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded
// (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1],
// but negative coords weren't in the original map.
// So loaded: (0,0) and its positive neighbors within range)
let wm = world.resource::<WalkabilityMap>();
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
// Chunk (2,2) should be unloaded (distance > 1 from (0,0))
assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
// Move player to chunk (2,2)
let mut q = world.query_filtered::<&mut TilePosition, With<PlayerCharacter>>();
let mut pos = q.single_mut(&mut world).unwrap();
pos.x = 80;
pos.y = 80;
// Advance tick so cadence gate passes
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
// Now chunk (2,2) and its neighbors should be loaded
let wm = world.resource::<WalkabilityMap>();
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 }));
// And chunk (0,0) should now be unloaded (distance 2 from (2,2))
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
}
#[test]
fn cadence_gate_skips_intermediate_ticks() {
let (mut world, mut schedule) = setup_streaming_world(
1,
10, // run every 10 ticks
TilePosition::new(80, 80, 0),
160,
160,
);
// tick=0 → runs (0 % 10 == 0)
schedule.run(&mut world);
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
// Reload all chunks to simulate "something loads chunks back"
world.insert_resource(WalkabilityMap::new(160, 160, 1));
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 25);
// tick=5 → should NOT run (5 % 10 != 0)
world.resource_mut::<SimulationTime>().tick = 5;
schedule.run(&mut world);
assert_eq!(
world.resource::<WalkabilityMap>().chunk_count(),
25,
"should not have run at tick 5"
);
// tick=10 → should run (10 % 10 == 0)
world.resource_mut::<SimulationTime>().tick = 10;
schedule.run(&mut world);
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
}
#[test]
fn zero_cadence_runs_every_tick() {
let (mut world, mut schedule) = setup_streaming_world(
1,
0, // cadence=0 means run every tick
TilePosition::new(80, 80, 0),
160,
160,
);
// tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs
schedule.run(&mut world);
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
}
#[test]
fn other_z_levels_untouched() {
// Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk).
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(ChunkLoadRadius { radius: 0 });
world.insert_resource(ChunkStreamingCadence { ticks: 1 });
// 2×2 chunks on 2 z-levels = 8 chunks total
world.insert_resource(WalkabilityMap::new(64, 64, 2));
world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
let mut schedule = Schedule::default();
schedule.add_systems(chunk_streaming);
// Before: 8 chunks (2×2×2)
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 8);
schedule.run(&mut world);
// After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4)
// Total: 1 + 4 = 5
let wm = world.resource::<WalkabilityMap>();
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
// z=1 chunks all still there
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 }));
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 }));
assert_eq!(wm.chunk_count(), 5);
}
}
+7
View File
@@ -22,6 +22,7 @@ use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
use crate::storyteller::EngagementRecord;
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
@@ -449,6 +450,7 @@ pub fn process_talk_interaction(
Option<&NpcColorIndex>,
Option<&KnowledgeGraph>,
)>,
mut engagement_query: Query<&mut EngagementRecord>,
) {
let Some(line_pool) = line_pool else {
return;
@@ -583,6 +585,11 @@ pub fn process_talk_interaction(
started_tick: time.tick,
});
// Engagement tracking (#570): increment conversation count for this NPC
if let Ok(mut record) = engagement_query.get_mut(target) {
record.conversation_count += 1;
}
// Trust progression (#324): successful talk warms the NPC
trust_queue.push(TrustEvent::TalkCompleted {
npc: target,
+10
View File
@@ -2,6 +2,7 @@
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
use crate::bridge::debug::DebugCommandBuffer;
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
@@ -98,6 +99,7 @@ pub fn process_player_input(
reset_triggers: Query<&RoomResetTrigger>,
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
mut save_load: Option<ResMut<SaveLoadPending>>,
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
door_states: Query<&DoorState>,
object_types: Query<&ObjectType>,
) {
@@ -119,6 +121,7 @@ pub fn process_player_input(
| PlayerAction::TeleportToHub
| PlayerAction::SaveGame { .. }
| PlayerAction::LoadGame { .. }
| PlayerAction::DebugCommand(_)
)
{
continue;
@@ -364,6 +367,13 @@ pub fn process_player_input(
tracing::warn!("LoadGame received but SaveLoadPending resource not registered");
}
}
PlayerAction::DebugCommand(cmd) => {
if let Some(ref mut buf) = debug_cmd_buffer {
buf.push(cmd);
} else {
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
}
}
}
}
+9
View File
@@ -4,6 +4,7 @@
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod chunk_streaming;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
@@ -50,6 +51,8 @@ impl Plugin for SimulationPlugin {
.init_resource::<crate::knowledge::EntityRegistry>()
.init_resource::<sound::SoundEventQueue>()
.init_resource::<spatial::NaiveSpatialIndex>()
.init_resource::<chunk_streaming::ChunkLoadRadius>()
.init_resource::<chunk_streaming::ChunkStreamingCadence>()
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
@@ -127,6 +130,12 @@ impl Plugin for SimulationPlugin {
.add_systems(
Update,
zone::detect_zone_crossings.after(movement::validate_movement),
)
// Chunk streaming (#578, D-012) — loads/unloads chunks around the player.
// Runs before input processing so chunks are available for the current tick.
.add_systems(
Update,
chunk_streaming::chunk_streaming.before(input::process_player_input),
);
tracing::debug!("SimulationPlugin initialized");
+42 -7
View File
@@ -16,10 +16,12 @@ use rand::Rng;
use crate::bridge::types::MonologueEvent;
use crate::content::ContentStoreResource;
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
/// Minimum ticks between monologue lines (prevents spam).
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
@@ -512,19 +514,25 @@ pub fn trigger_event_monologue(
),
With<PlayerCharacter>,
>,
registry: Option<Res<EntityRegistry>>,
mut engagement_query: Query<&mut EngagementRecord>,
) {
// Drain post_conversation queue unconditionally — consumed this tick.
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else {
// Drain post_conversation queue even without a player
post_conv_queue.drain();
return;
};
// Don't override existing monologue from higher-priority systems
if buffer.event.is_some() {
post_conv_queue.drain();
return;
}
// Save previous observation tick before update — needed for NPC attribution (#570)
let previous_observation_tick = state.last_observation_tick;
// Determine which trigger to fire (priority order)
let trigger = if observation_queue
.as_ref()
@@ -540,15 +548,12 @@ pub fn trigger_event_monologue(
Some("hear_sound")
} else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) {
Some("witness_interaction")
} else if !post_conv_queue.is_empty() {
} else if !post_conv_npcs.is_empty() {
Some("post_conversation")
} else {
None
};
// Always drain post_conversation queue (consumed this tick)
post_conv_queue.drain();
// Update observation tracking regardless of whether we fire
if let Some(ref obs_queue) = observation_queue {
if !obs_queue.is_empty() {
@@ -588,6 +593,36 @@ pub fn trigger_event_monologue(
id,
time.tick
);
// Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs.
// Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific.
match trigger {
"observe_npc" => {
// Attribute to all NPCs whose NewEntity event triggered this monologue
if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, &registry) {
for event in obs_q.iter() {
if event.tick > previous_observation_tick {
if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger {
if let Some(npc_entity) = reg.to_entity(sid) {
if let Ok(mut record) = engagement_query.get_mut(npc_entity) {
record.monologue_trigger_count += 1;
}
}
}
}
}
}
}
"post_conversation" => {
// Attribute to the NPC(s) whose conversation just ended
for npc in &post_conv_npcs {
if let Ok(mut record) = engagement_query.get_mut(*npc) {
record.monologue_trigger_count += 1;
}
}
}
_ => {} // hear_sound, witness_interaction: no NPC-specific attribution
}
}
/// Check if any NewEntity observation events exist that we haven't processed.
+176 -7
View File
@@ -1,6 +1,7 @@
// Tile-based movement and collision system
// Implements Sprint 1 ticket #236: walkability map and movement validation
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
// Extended by #576: TileKind layer per tile (server-authoritative tile classification)
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
// Y-down convention: North = y-1, South = y+1
@@ -16,6 +17,30 @@ use crate::simulation::stance::Stance;
/// Chunk size in tiles (32x32 per chunk)
pub const CHUNK_SIZE: i32 = 32;
/// Server-side authoritative tile classification (#576, D-012).
///
/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering)
/// but serves a different purpose: simulation logic and tile authoring.
///
/// Tile format for location YAML authoring (#577):
/// - `F` = Floor (walkable, open space)
/// - `W` = Wall (solid obstacle, blocks movement and LOS)
/// - `V` = Void (out-of-bounds / unloaded; treated as blocked)
/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TileKind {
/// Walkable floor tile. Default for populated chunks.
#[default]
Floor,
/// Solid wall tile. Blocks movement and line-of-sight.
Wall,
/// Void / unloaded tile. Out-of-bounds or ungenerated space.
Void,
/// Restricted tile. Blocked for standard movement but accessible
/// to authorised entities (e.g. locked zones, maintenance airlocks).
Restricted,
}
/// Marker component identifying the player-controlled entity.
#[derive(Component, Debug)]
pub struct PlayerCharacter;
@@ -121,7 +146,7 @@ impl TilePosition {
}
/// Get the chunk coordinate this tile belongs to.
fn chunk_coord(&self) -> ChunkCoord {
pub fn chunk_coord(&self) -> ChunkCoord {
ChunkCoord {
cx: self.x.div_euclid(CHUNK_SIZE),
cy: self.y.div_euclid(CHUNK_SIZE),
@@ -143,22 +168,43 @@ pub struct ChunkCoord {
pub z: i32,
}
/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
/// Per-tile storage cell: walkability flag and tile classification.
/// Private — exposed only through WalkabilityMap's public API.
#[derive(Debug, Clone, Copy)]
struct TileCell {
walkable: bool,
kind: TileKind,
}
/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
/// Extended by #576 to carry TileKind alongside the walkability bool.
#[derive(Debug, Clone)]
struct ChunkData {
tiles: Vec<bool>, // CHUNK_SIZE * CHUNK_SIZE, true = walkable
tiles: Vec<TileCell>,
}
impl ChunkData {
fn new_walkable() -> Self {
Self {
tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize],
tiles: vec![
TileCell {
walkable: true,
kind: TileKind::Floor
};
(CHUNK_SIZE * CHUNK_SIZE) as usize
],
}
}
fn new_blocked() -> Self {
Self {
tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize],
tiles: vec![
TileCell {
walkable: false,
kind: TileKind::Wall
};
(CHUNK_SIZE * CHUNK_SIZE) as usize
],
}
}
@@ -166,12 +212,24 @@ impl ChunkData {
(ly * CHUNK_SIZE + lx) as usize
}
/// Returns the walkability flag for a tile (backward-compatible internal accessor).
fn get(&self, lx: i32, ly: i32) -> bool {
self.tiles[Self::index(lx, ly)]
self.tiles[Self::index(lx, ly)].walkable
}
/// Sets only the walkability flag; tile kind is unchanged.
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
self.tiles[Self::index(lx, ly)] = walkable;
self.tiles[Self::index(lx, ly)].walkable = walkable;
}
/// Returns the tile kind for a cell.
fn get_kind(&self, lx: i32, ly: i32) -> TileKind {
self.tiles[Self::index(lx, ly)].kind
}
/// Sets the tile kind for a cell; walkability is unchanged.
fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) {
self.tiles[Self::index(lx, ly)].kind = kind;
}
}
@@ -226,6 +284,7 @@ impl WalkabilityMap {
}
/// Set walkability of a tile. Creates the chunk if it doesn't exist.
/// Does not change the tile's TileKind.
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
@@ -236,6 +295,27 @@ impl WalkabilityMap {
chunk.set(lx, ly, walkable);
}
/// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks.
pub fn tile_kind(&self, pos: &TilePosition) -> TileKind {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
self.chunks
.get(&coord)
.map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly))
}
/// Set the tile kind at a position. Creates the chunk if it doesn't exist.
/// Does not change the tile's walkability.
pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) {
let coord = pos.chunk_coord();
let (lx, ly) = pos.local_offset();
let chunk = self
.chunks
.entry(coord)
.or_insert_with(ChunkData::new_blocked);
chunk.set_kind(lx, ly, kind);
}
/// Check if a chunk is loaded.
pub fn has_chunk(&self, coord: &ChunkCoord) -> bool {
self.chunks.contains_key(coord)
@@ -259,6 +339,12 @@ impl WalkabilityMap {
pub fn chunk_count(&self) -> usize {
self.chunks.len()
}
/// Returns the coordinates of all loaded chunks (#578).
/// Used by the chunk streaming system to determine which chunks to unload.
pub fn loaded_chunk_coords(&self) -> Vec<ChunkCoord> {
self.chunks.keys().copied().collect()
}
}
/// Component representing an intent to move to a target tile.
@@ -366,6 +452,89 @@ pub fn validate_movement(
mod tests {
use super::*;
// -----------------------------------------------------------------------
// TileKind layer tests (#576)
// -----------------------------------------------------------------------
#[test]
fn tile_kind_default_is_floor_for_walkable_chunk() {
let map = WalkabilityMap::new(10, 10, 1);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor);
assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor);
}
#[test]
fn tile_kind_unloaded_chunk_returns_void() {
let map = WalkabilityMap::new(10, 10, 1);
// Negative coords → unloaded chunk → Void
assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void);
assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void);
// z-level not loaded → Void
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void);
}
#[test]
fn tile_kind_round_trip() {
let mut map = WalkabilityMap::new(10, 10, 1);
let pos = TilePosition::new(5, 5, 0);
map.set_tile_kind(&pos, TileKind::Wall);
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
map.set_tile_kind(&pos, TileKind::Restricted);
assert_eq!(map.tile_kind(&pos), TileKind::Restricted);
map.set_tile_kind(&pos, TileKind::Void);
assert_eq!(map.tile_kind(&pos), TileKind::Void);
map.set_tile_kind(&pos, TileKind::Floor);
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
}
#[test]
fn tile_kind_independent_of_walkability() {
let mut map = WalkabilityMap::new(10, 10, 1);
let pos = TilePosition::new(3, 3, 0);
// Start: Floor + walkable
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
assert!(map.can_move_to(&pos));
// Set walkable = false; kind should remain Floor
map.set_walkable(&pos, false);
assert!(!map.can_move_to(&pos));
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
// Set kind = Wall; walkability should remain false
map.set_tile_kind(&pos, TileKind::Wall);
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
assert!(!map.can_move_to(&pos));
// Restore walkable = true; kind should stay Wall
map.set_walkable(&pos, true);
assert!(map.can_move_to(&pos));
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
}
#[test]
fn tile_kind_set_creates_chunk_on_demand() {
let mut map = WalkabilityMap::new(1, 1, 1);
let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk
assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
map.set_tile_kind(&new_chunk_pos, TileKind::Restricted);
assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted);
}
#[test]
fn blocked_chunk_default_kind_is_wall() {
let map = WalkabilityMap::new_blocked(32, 32, 1);
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall);
assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall);
}
#[test]
fn tile_position_equality() {
let pos1 = TilePosition::new(5, 10, 0);
+84 -3
View File
@@ -31,7 +31,10 @@ use crate::simulation::interaction::DoorState;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use crate::content::template::TriangleCrisisEventQueue;
use crate::storyteller::{ContaminationActive, ContaminationEventQueue};
use crate::storyteller::{
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
TriangleActivatedQueue,
};
/// Errors from save/load operations (#553).
#[derive(Debug, Error)]
@@ -149,6 +152,12 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
contamination_active: world
.get_resource::<ContaminationActive>()
.map_or(false, |c| c.0),
activated_count: world
.get_resource::<ActivationState>()
.map_or(0, |a| a.activated_count),
last_activation_tick: world
.get_resource::<ActivationState>()
.and_then(|a| a.last_activation_tick),
};
let bytes = state
@@ -268,10 +277,18 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
// Restore contamination state (#254) — prevents double-firing on reload.
world.insert_resource(ContaminationActive(state.contamination_active));
// Reset event queues — prevent stale events from the pre-load world
// leaking into the post-load simulation.
// Restore activation state (#572) — prevents double-activation on reload.
world.insert_resource(ActivationState {
activated_count: state.activated_count,
last_activation_tick: state.last_activation_tick,
});
// Reset event queues and transient buffers — prevent stale events/history
// from the pre-load world leaking into the post-load simulation.
world.insert_resource(ContaminationEventQueue::default());
world.insert_resource(TriangleCrisisEventQueue::default());
world.insert_resource(TriangleActivatedQueue::default());
world.insert_resource(MovementHistoryBuffer::default());
// Restore door open states (#246) — find door entities by StableId and toggle.
if !state.open_doors.is_empty() {
@@ -401,6 +418,7 @@ mod tests {
w.insert_resource(RelationshipGraph::new());
w.init_resource::<EntityRegistry>();
w.init_resource::<ContaminationActive>();
w.init_resource::<ActivationState>();
w
}
@@ -591,6 +609,8 @@ mod tests {
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
@@ -862,4 +882,65 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// ActivationState roundtrip (#572, Task #12)
// -----------------------------------------------------------------------
#[test]
fn load_from_file_restores_activation_state() {
let mut world = minimal_world();
// Set activation state: 1 activation at tick 500
world.insert_resource(ActivationState {
activated_count: 1,
last_activation_tick: Some(500),
});
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Reset activation state to defaults before load
world.insert_resource(ActivationState::default());
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
assert!(world.resource::<ActivationState>().last_activation_tick.is_none());
load_from_file(&path, &mut world).expect("load");
let state = world.resource::<ActivationState>();
assert_eq!(
state.activated_count, 1,
"activated_count must survive save/load"
);
assert_eq!(
state.last_activation_tick,
Some(500),
"last_activation_tick must survive save/load"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_restores_zero_activation_state() {
// Verify that saves with no activations restore correctly (serde(default))
let mut world = minimal_world();
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Pollute state before load
world.insert_resource(ActivationState {
activated_count: 5,
last_activation_tick: Some(9999),
});
load_from_file(&path, &mut world).expect("load");
let state = world.resource::<ActivationState>();
assert_eq!(state.activated_count, 0);
assert!(state.last_activation_tick.is_none());
let _ = std::fs::remove_file(&path);
}
}
+16
View File
@@ -55,6 +55,7 @@ use crate::npc::relationships::RelationshipGraph;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
use crate::storyteller::EngagementRecord;
/// Current format version. Bump on any breaking schema change.
pub const SAVE_FORMAT_VERSION: u8 = 1;
@@ -111,6 +112,16 @@ pub struct SaveStateV1 {
/// and apply a duplicate tension delta to all ActiveFork triangles.
#[serde(default)]
pub contamination_active: bool,
/// Number of triangles activated this session (#572).
/// Persisted to prevent double-activation on save/load — without this,
/// reloading a save after activation would reset the one-shot guard
/// and allow a second triangle to be activated.
#[serde(default)]
pub activated_count: u32,
/// Tick at which the most recent triangle activation occurred (#572).
/// `None` if no activation yet. Persisted alongside `activated_count`.
#[serde(default)]
pub last_activation_tick: Option<u64>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
@@ -363,6 +374,7 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
NpcVisionState::default(),
NpcMemory::default(),
PlayerAwareness::default(),
EngagementRecord::default(),
))
.id();
@@ -425,6 +437,8 @@ mod tests {
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
}
}
@@ -824,6 +838,8 @@ mod tests {
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
};
let bytes = save.to_bytes().expect("serialize");
+772 -2
View File
@@ -12,11 +12,54 @@
//! 1. Sets `ContaminationActive` resource to true (one-shot)
//! 2. Applies a tension delta to all `ActiveFork` triangles
//! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.)
//!
//! ## Activation Lifecycle (#572)
//!
//! After contamination, the activation pass (#162, #579) runs on a 10-tick
//! cadence, scoring NPCs by engagement and activating the best-fit triangle.
//!
//! **v0.1 lifecycle rules (single-activation model):**
//!
//! 1. **One activation per session.** The vertical slice is a ~30-minute
//! experience. Once a triangle is activated, the storyteller does not
//! activate additional triangles. `ActivationState::Activated` is the
//! terminal state for the storyteller within a single playthrough.
//!
//! 2. **No concurrent activations.** At most one triangle can be in the
//! `TrianglePhase::Active` state at any time. This is trivially enforced
//! by rule 1 for v0.1 — future versions (v0.3+) will need a concurrency
//! limit and priority queue.
//!
//! 3. **No cooldown.** Since only one activation fires, there is no cooldown
//! period between activations. Future multi-activation will need
//! `ACTIVATION_COOLDOWN_TICKS` — stub the constant now at 0.
//!
//! 4. **Resolution is terminal.** When a triangle reaches `TrianglePhase::Resolved`
//! (player completes investigation or consequences fire), it stays resolved.
//! The storyteller does not re-activate resolved triangles. For v0.1 this
//! is moot (one-shot), but the activation pass must still check for it.
//!
//! 5. **Activation cadence.** The activation pass polls every
//! `ACTIVATION_CADENCE_TICKS` (10 ticks = 1 game-second). This is cheap
//! because the pass no-ops if `ActivationState` is already `Activated`.
//!
//! **Future extensions (v0.3+):**
//! - `MAX_CONCURRENT_ACTIVATIONS` > 1
//! - `ACTIVATION_COOLDOWN_TICKS` > 0 between successive activations
//! - `SelectionStrategy::WeightedRandom` alongside `HighestScore`
//! - Hubris wall gating (activation blocked above a threshold)
use std::collections::VecDeque;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::content::template::{TriangleClassification, TriangleState};
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
@@ -34,10 +77,68 @@ pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10;
/// 0–100 scale. Not yet consumed — placeholder for future confrontation system.
pub const CONFRONTATION_THRESHOLD: u8 = 75;
// --- Activation lifecycle constants (#572) ----------------------------------
/// How often the activation pass runs, in ticks.
/// 10 ticks = 1 game-second (D-031). Cheap — no-ops if already activated.
pub const ACTIVATION_CADENCE_TICKS: u64 = 10;
/// Maximum concurrent triangle activations. v0.1 = 1 (single-activation).
pub const MAX_CONCURRENT_ACTIVATIONS: u32 = 1;
/// Cooldown between successive activations, in ticks. v0.1 = 0 (one-shot).
/// Future multi-activation (v0.3+) will set this to e.g. 600 (1 game-minute).
pub const ACTIVATION_COOLDOWN_TICKS: u64 = 0;
/// Engagement window for NPC co-presence scoring (#162 step 2).
/// 3000 ticks = 5 game-minutes = ~5 real minutes at 10 tps.
pub const ENGAGEMENT_WINDOW_TICKS: u64 = 3000;
// --- Engagement scoring weights (#162 step 3) -------------------------------
/// Weight per conversation (capped at `CONVERSATION_CAP`).
pub const ENGAGEMENT_WEIGHT_CONVERSATION: f32 = 2.0;
/// Maximum conversations counted toward engagement score.
pub const CONVERSATION_CAP: u32 = 5;
/// Weight per tick of observation time.
/// 200 ticks (~20 real seconds) = 1 point.
pub const ENGAGEMENT_WEIGHT_OBSERVATION: f32 = 0.005;
/// Weight per monologue trigger — richest signal.
pub const ENGAGEMENT_WEIGHT_MONOLOGUE: f32 = 8.0;
/// Proximity threshold (Chebyshev tiles) for co-presence detection in activation_pass.
/// Matches NaturalVision forward_range (D-015, D-017): 20 tiles.
pub const COPRESENCE_THRESHOLD: i32 = 20;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Per-NPC engagement metrics for storyteller activation scoring (#570, #162).
///
/// Tracks cumulative engagement signals the player has generated with a specific NPC.
/// Used by `activation_pass()` (#579) to score NPCs and select the best-fit triangle.
///
/// All three fields are additive counters — never overwritten, only incremented.
///
/// **Write sites:**
/// - `observation_time_ticks`: `perception::observation::emit_observation_events` — +1 per tick in LOS
/// - `conversation_count`: `simulation::dialogue::process_talk_interaction` — +1 on Talk start
/// - `monologue_trigger_count`:`simulation::monologue::trigger_event_monologue` — +1 on NPC-context fire
#[derive(Component, Debug, Clone, Default)]
pub struct EngagementRecord {
/// Cumulative ticks this NPC was in the player's direct LOS.
pub observation_time_ticks: u64,
/// Number of player-initiated conversation starts with this NPC.
pub conversation_count: u32,
/// Number of monologue triggers fired in this NPC's context
/// (observe_npc or post_conversation referencing this entity).
pub monologue_trigger_count: u32,
}
/// Whether contamination has been activated by the storyteller.
///
/// Once set to `true`, it stays true for the remainder of the session.
@@ -45,6 +146,56 @@ pub const CONFRONTATION_THRESHOLD: u8 = 75;
#[derive(Resource, Debug, Clone, Default)]
pub struct ContaminationActive(pub bool);
/// Storyteller activation state (#572).
///
/// Tracks how many triangles have been activated this session. For v0.1 this
/// is a simple boolean gate — once `activated_count >= MAX_CONCURRENT_ACTIVATIONS`,
/// the activation pass no-ops. Persisted in `SaveStateV1`.
#[derive(Resource, Debug, Clone)]
pub struct ActivationState {
/// Number of triangles activated this session.
pub activated_count: u32,
/// Tick at which the most recent activation occurred. `None` if no activation yet.
pub last_activation_tick: Option<u64>,
}
impl Default for ActivationState {
fn default() -> Self {
Self {
activated_count: 0,
last_activation_tick: None,
}
}
}
impl ActivationState {
/// Whether the activation pass should attempt to activate a new triangle.
///
/// For v0.1 this returns `false` after the first activation. Future versions
/// will also check cooldown elapsed since `last_activation_tick`.
pub fn can_activate(&self, _current_tick: u64) -> bool {
if self.activated_count >= MAX_CONCURRENT_ACTIVATIONS {
return false;
}
// v0.1: cooldown is 0, so no cooldown check needed.
// Future: check (current_tick - last_activation_tick) >= ACTIVATION_COOLDOWN_TICKS
if ACTIVATION_COOLDOWN_TICKS > 0 {
if let Some(last) = self.last_activation_tick {
if _current_tick.saturating_sub(last) < ACTIVATION_COOLDOWN_TICKS {
return false;
}
}
}
true
}
/// Record that a triangle was activated at the given tick.
pub fn record_activation(&mut self, tick: u64) {
self.activated_count += 1;
self.last_activation_tick = Some(tick);
}
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
@@ -84,6 +235,129 @@ impl ContaminationEventQueue {
}
}
/// Emitted when the activation pass selects and activates a triangle (#162, #572).
///
/// Downstream consumers:
/// - Tell system (D-024 axis 9): escalate tell behavior frequency
/// - Routine scheduler: activated triangle NPCs may deviate from routine
/// - Monologue system (D-016): unlock `triangle_activated` context lines
/// - FRIEND arc manager (D-034): advance relationship phase if applicable
#[derive(Debug, Clone)]
pub struct TriangleActivatedEvent {
/// Which triangle was activated.
pub triangle_id: TriangleId,
/// Tick at which activation occurred.
pub tick: u64,
/// The NPC entity that scored highest (activation anchor).
pub anchor_entity: Entity,
/// Engagement score of the anchor NPC.
pub anchor_score: f32,
}
/// Resource queue for triangle activation events.
///
/// Same drain pattern as `ContaminationEventQueue`. Populated by
/// `activation_pass()` (#579), consumed by downstream systems.
#[derive(Resource, Default)]
pub struct TriangleActivatedQueue {
pub events: Vec<TriangleActivatedEvent>,
}
impl TriangleActivatedQueue {
pub fn push(&mut self, event: TriangleActivatedEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<TriangleActivatedEvent> {
std::mem::take(&mut self.events)
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
// ---------------------------------------------------------------------------
// Movement history (#571)
// ---------------------------------------------------------------------------
/// Ring buffer of recent player tile positions for storyteller proximity scoring.
///
/// Retains the player's path over the last `ENGAGEMENT_WINDOW_TICKS` ticks.
/// Provides `npcs_copresent_in_window` to identify which NPCs have shared
/// space with the player recently, feeding the activation pass (#162, #579).
#[derive(Resource, Debug, Clone)]
pub struct MovementHistoryBuffer {
positions: VecDeque<TilePosition>,
}
impl Default for MovementHistoryBuffer {
fn default() -> Self {
Self {
positions: VecDeque::with_capacity(ENGAGEMENT_WINDOW_TICKS as usize),
}
}
}
impl MovementHistoryBuffer {
/// Append the player's current position for this tick.
///
/// Maintains a maximum of `ENGAGEMENT_WINDOW_TICKS` entries by evicting
/// the oldest position when the buffer is full.
pub fn append(&mut self, pos: TilePosition) {
if self.positions.len() >= ENGAGEMENT_WINDOW_TICKS as usize {
self.positions.pop_front();
}
self.positions.push_back(pos);
}
/// Returns entities from `npc_positions` whose position is within
/// `threshold` tiles (Chebyshev distance) of any recorded player position
/// in the buffer on the same z-level.
///
/// Used by the activation pass (#579) to identify which NPCs the player
/// was co-present with during the engagement window.
pub fn npcs_copresent_in_window(
&self,
npc_positions: impl Iterator<Item = (Entity, TilePosition)>,
threshold: i32,
) -> Vec<Entity> {
npc_positions
.filter(|(_, npc_pos)| {
self.positions.iter().any(|player_pos| {
player_pos.z == npc_pos.z
&& (player_pos.x - npc_pos.x).abs() <= threshold
&& (player_pos.y - npc_pos.y).abs() <= threshold
})
})
.map(|(entity, _)| entity)
.collect()
}
/// Number of recorded positions in the buffer.
pub fn len(&self) -> usize {
self.positions.len()
}
/// True if the buffer has no recorded positions.
pub fn is_empty(&self) -> bool {
self.positions.is_empty()
}
}
/// System: append the player's current position to the movement history buffer.
///
/// Runs each tick after `validate_movement`. Maintains the sliding
/// `ENGAGEMENT_WINDOW_TICKS` window consumed by the activation pass (#162).
pub fn append_player_history(
mut history: ResMut<MovementHistoryBuffer>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
) {
if let Ok(pos) = player_query.single() {
history.append(*pos);
}
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -95,7 +369,22 @@ impl Plugin for StorytellerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ContaminationActive>()
.init_resource::<ContaminationEventQueue>()
.add_systems(Update, tick_contamination_activation);
.init_resource::<ActivationState>()
.init_resource::<TriangleActivatedQueue>()
.init_resource::<MovementHistoryBuffer>()
.add_systems(Update, tick_contamination_activation)
.add_systems(
Update,
append_player_history
.after(crate::simulation::movement::validate_movement),
)
.add_systems(
Update,
activation_pass
.after(append_player_history)
.after(tick_contamination_activation)
.before(crate::simulation::time::advance_tick),
);
tracing::debug!("StorytellerPlugin initialized");
}
@@ -153,6 +442,166 @@ pub fn tick_contamination_activation(
);
}
/// Helper: compute engagement score from an EngagementRecord.
///
/// Formula: capped conversations × weight + observation ticks × weight + monologue triggers × weight.
/// Implements #162 step 3 scoring.
fn compute_engagement_score(record: &EngagementRecord) -> f32 {
let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32
* ENGAGEMENT_WEIGHT_CONVERSATION;
// Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24),
// where f32 can no longer distinguish adjacent integers.
let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32;
let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE;
conv_score + obs_score + mono_score
}
/// System: storyteller activation pass — select and activate the best-fit triangle.
///
/// Implements #162 steps 1–6. Runs on a 10-tick cadence after contamination fires.
///
/// Steps:
/// 1. Gate: contamination active + activation limit not reached + cadence check.
/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer).
/// 3. Engagement scoring: score each co-present NPC via EngagementRecord.
/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC.
/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+).
/// Holds (no activation) if no triangle-assigned NPC is co-present.
/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState.
#[allow(clippy::too_many_arguments)]
pub fn activation_pass(
time: Res<SimulationTime>,
contamination: Res<ContaminationActive>,
mut activation_state: ResMut<ActivationState>,
history: Res<MovementHistoryBuffer>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut triangles: Query<(Entity, &mut TriangleState), With<ActiveSim>>,
npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With<Npc>, With<ActiveSim>)>,
mut event_queue: ResMut<TriangleActivatedQueue>,
) {
// Gate 1: contamination must be active
if !contamination.0 {
return;
}
// Gate 2: activation limit
if !activation_state.can_activate(time.tick) {
return;
}
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS.
// Guard: at tick 0 all engagement scores are 0.0 and selection is random
// (e.g. contamination pre-set in a save file). Skip tick 0.
if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 {
return;
}
// Step 2: co-presence query
let npc_positions: Vec<(Entity, TilePosition)> =
npcs.iter().map(|(e, pos, _)| (e, *pos)).collect();
let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
// Deduplicate: the co-presence query can return the same entity more than once
// if multiple player positions fall near the same NPC. Without dedup, the NPC
// gets added to candidates twice, doubling its RNG weight.
copresent.sort_unstable();
copresent.dedup();
// Snapshot simmering triangles for read (avoids double-borrow during activation write)
let simmering: Vec<(Entity, TriangleState)> = triangles
.iter()
.filter(|(_, s)| s.phase == TrianglePhase::Simmering)
.map(|(e, s)| (e, s.clone()))
.collect();
if simmering.is_empty() {
tracing::warn!("activation_pass: no Simmering triangles — holding");
return;
}
// Steps 3+4: score co-present NPCs that belong to a Simmering triangle.
// Unentangled NPCs (not in any triangle) are excluded; v0.1 skips D-025 routing.
let mut candidates: Vec<(Entity, Entity, f32)> = Vec::new(); // (npc_entity, tri_entity, score)
for &npc_entity in &copresent {
let Some(stable_id) = registry.to_stable(npc_entity) else {
continue;
};
let score = npcs
.get(npc_entity)
.ok()
.and_then(|(_, _, r)| r)
.map(compute_engagement_score)
.unwrap_or(0.0);
for (tri_entity, tri_state) in &simmering {
if tri_state
.role_assignments
.values()
.any(|sid| *sid == stable_id)
{
candidates.push((npc_entity, *tri_entity, score));
break; // each NPC is assigned to at most one triangle per pass
}
}
}
if candidates.is_empty() {
tracing::warn!(
"activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding",
time.tick
);
return;
}
// Step 5: select best candidate; SimRng tie-break among equal top scores (D-010)
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let top_score = candidates[0].2;
let top_count = candidates
.iter()
.take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0))
.count();
let selected_idx = if top_count > 1 {
rng.rng.random_range(0..top_count)
} else {
0
};
let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx];
// Retrieve triangle_id for the event.
// Guard: the entity was in the simmering snapshot we collected above, but in
// theory it could have been despawned between snapshot and lookup (unlikely but
// a panic in the gameplay loop is unacceptable).
let Some(triangle_id) = simmering
.iter()
.find(|(e, _)| *e == tri_entity)
.map(|(_, s)| s.triangle_id)
else {
tracing::error!(
"activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}",
tri_entity, time.tick
);
return;
};
// Step 6: set triangle phase to Active
if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) {
tri_state.phase = TrianglePhase::Active;
}
// Step 7: record activation and emit event
activation_state.record_activation(time.tick);
event_queue.push(TriangleActivatedEvent {
triangle_id,
tick: time.tick,
anchor_entity,
anchor_score,
});
tracing::info!(
"activation_pass: activated triangle {} at tick {} (anchor score: {:.2})",
triangle_id.0,
time.tick,
anchor_score,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -311,4 +760,325 @@ mod tests {
let state = q.single(&world).unwrap();
assert_eq!(state.tension, 255);
}
// --- ActivationState lifecycle tests (#572) ---
#[test]
fn activation_state_allows_first_activation() {
let state = ActivationState::default();
assert!(state.can_activate(500));
}
#[test]
fn activation_state_blocks_after_max() {
let mut state = ActivationState::default();
state.record_activation(500);
// v0.1: MAX_CONCURRENT_ACTIVATIONS = 1, so second is blocked
assert!(!state.can_activate(600));
}
#[test]
fn activation_state_records_tick() {
let mut state = ActivationState::default();
assert_eq!(state.activated_count, 0);
assert!(state.last_activation_tick.is_none());
state.record_activation(1000);
assert_eq!(state.activated_count, 1);
assert_eq!(state.last_activation_tick, Some(1000));
}
#[test]
fn triangle_activated_queue_drain() {
let mut queue = TriangleActivatedQueue::default();
assert!(queue.is_empty());
let mut world = World::new();
let entity = world.spawn_empty().id();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(0, "test"),
tick: 500,
anchor_entity: entity,
anchor_score: 12.5,
});
assert!(!queue.is_empty());
let events = queue.drain();
assert_eq!(events.len(), 1);
assert!(queue.is_empty());
}
// --- MovementHistoryBuffer tests (#571) ---
#[test]
fn movement_history_append_and_len() {
let mut buf = MovementHistoryBuffer::default();
assert!(buf.is_empty());
buf.append(TilePosition::new(1, 2, 0));
buf.append(TilePosition::new(3, 4, 0));
assert_eq!(buf.len(), 2);
}
#[test]
fn movement_history_evicts_oldest_at_capacity() {
let mut buf = MovementHistoryBuffer::default();
// Fill to capacity
for i in 0..ENGAGEMENT_WINDOW_TICKS as i32 {
buf.append(TilePosition::new(i, 0, 0));
}
assert_eq!(buf.len(), ENGAGEMENT_WINDOW_TICKS as usize);
// One more — should evict x=0
buf.append(TilePosition::new(9999, 0, 0));
assert_eq!(
buf.len(),
ENGAGEMENT_WINDOW_TICKS as usize,
"buffer must not grow beyond ENGAGEMENT_WINDOW_TICKS"
);
// The oldest entry (x=0) should be gone; newest (x=9999) should be present
let mut world = World::new();
let entity_old = world.spawn_empty().id();
let entity_new = world.spawn_empty().id();
let copresent = buf.npcs_copresent_in_window(
[(entity_old, TilePosition::new(0, 0, 0))].into_iter(),
0,
);
assert!(
copresent.is_empty(),
"NPC at evicted position x=0 should not be copresent"
);
let copresent_new = buf.npcs_copresent_in_window(
[(entity_new, TilePosition::new(9999, 0, 0))].into_iter(),
0,
);
assert_eq!(copresent_new.len(), 1, "NPC at newest position should be copresent");
}
#[test]
fn npcs_copresent_finds_nearby_npc() {
let mut buf = MovementHistoryBuffer::default();
buf.append(TilePosition::new(10, 10, 0));
buf.append(TilePosition::new(11, 10, 0));
let mut world = World::new();
let nearby = world.spawn_empty().id();
let distant = world.spawn_empty().id();
// NPC 1 tile from a history position — within threshold 2
let result = buf.npcs_copresent_in_window(
[
(nearby, TilePosition::new(10, 11, 0)),
(distant, TilePosition::new(50, 50, 0)),
]
.into_iter(),
2,
);
assert!(result.contains(&nearby), "nearby NPC should be copresent");
assert!(!result.contains(&distant), "distant NPC should not be copresent");
}
#[test]
fn npcs_copresent_different_z_not_included() {
let mut buf = MovementHistoryBuffer::default();
buf.append(TilePosition::new(10, 10, 0));
let mut world = World::new();
let npc = world.spawn_empty().id();
// Same x/y but different z
let result = buf.npcs_copresent_in_window(
[(npc, TilePosition::new(10, 10, 1))].into_iter(),
0,
);
assert!(
result.is_empty(),
"NPC on different z-level must not be copresent"
);
}
#[test]
fn append_player_history_system_appends_position() {
let mut world = World::new();
world.init_resource::<MovementHistoryBuffer>();
world.spawn((PlayerCharacter, TilePosition::new(5, 7, 0)));
let mut schedule = Schedule::default();
schedule.add_systems(append_player_history);
schedule.run(&mut world);
let buf = world.resource::<MovementHistoryBuffer>();
assert_eq!(buf.len(), 1);
}
#[test]
fn append_player_history_system_no_player_no_panic() {
// Should silently no-op if no player entity is present
let mut world = World::new();
world.init_resource::<MovementHistoryBuffer>();
let mut schedule = Schedule::default();
schedule.add_systems(append_player_history);
schedule.run(&mut world);
assert!(world.resource::<MovementHistoryBuffer>().is_empty());
}
// --- activation_pass tests (#579) ---
fn setup_activation_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(ContaminationActive(true));
world.init_resource::<ActivationState>();
world.init_resource::<MovementHistoryBuffer>();
world.init_resource::<EntityRegistry>();
world.init_resource::<TriangleActivatedQueue>();
world.insert_resource(crate::simulation::rng::SimRng::new(42));
let mut schedule = Schedule::default();
schedule.add_systems(activation_pass);
(world, schedule)
}
#[test]
fn activation_pass_blocked_before_contamination() {
let (mut world, mut schedule) = setup_activation_world();
world.insert_resource(ContaminationActive(false));
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
schedule.run(&mut world);
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
}
#[test]
fn activation_pass_blocked_off_cadence() {
let (mut world, mut schedule) = setup_activation_world();
// Off-cadence tick (not divisible by ACTIVATION_CADENCE_TICKS)
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS + 3;
schedule.run(&mut world);
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
}
#[test]
fn activation_pass_blocked_after_max_activations() {
let (mut world, mut schedule) = setup_activation_world();
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
// Record activation up to the limit
world.resource_mut::<ActivationState>().record_activation(0);
schedule.run(&mut world);
// Should not fire again
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
}
#[test]
fn activation_pass_holds_when_no_triangle_npc_copresent() {
// No NPCs present at all — no candidates, should hold (not activate a random triangle)
let (mut world, mut schedule) = setup_activation_world();
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
// Spawn a Simmering triangle (but no NPCs)
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
schedule.run(&mut world);
// Should NOT activate — no co-present NPC is assigned to any triangle
assert!(
world.resource::<TriangleActivatedQueue>().is_empty(),
"activation should hold when no triangle-assigned NPC is copresent"
);
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
}
#[test]
fn activation_pass_activates_triangle_via_copresent_npc() {
let (mut world, mut schedule) = setup_activation_world();
let tick = ACTIVATION_CADENCE_TICKS;
world.resource_mut::<SimulationTime>().tick = tick;
// Register NPC entity in EntityRegistry to get a StableId
let npc_entity = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(5, 5, 0))).id();
let stable_id = world.resource_mut::<EntityRegistry>().register(npc_entity);
// Spawn a Simmering triangle with the NPC in a role
let mut tri = make_triangle(TriangleClassification::ActiveFork);
tri.role_assignments.insert(RoleId::new("a"), stable_id);
let tri_entity = world.spawn((tri, ActiveSim)).id();
// Put the player nearby in the history buffer
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(5, 5, 0));
schedule.run(&mut world);
let events = world.resource_mut::<TriangleActivatedQueue>().drain();
assert_eq!(events.len(), 1);
let tri_state = world.get::<TriangleState>(tri_entity).unwrap();
assert_eq!(tri_state.phase, TrianglePhase::Active, "triangle containing copresent NPC should be Active");
}
#[test]
fn activation_pass_does_not_fire_twice() {
// Verify the one-shot guard: a real first activation must happen, then confirm
// the second pass is blocked by ActivationState (not vacuously by empty candidates).
let (mut world, mut schedule) = setup_activation_world();
// Register an NPC and assign it to a triangle role so candidates are non-empty
let npc = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(1, 1, 0))).id();
let stable_id = world.resource_mut::<EntityRegistry>().register(npc);
let mut tri = make_triangle(TriangleClassification::ActiveFork);
tri.role_assignments.insert(RoleId::new("a"), stable_id);
world.spawn((tri, ActiveSim));
// Put the player nearby so the NPC is copresent
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(1, 1, 0));
// First run — should activate
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
schedule.run(&mut world);
let first_events = world.resource_mut::<TriangleActivatedQueue>().drain();
assert_eq!(first_events.len(), 1, "first pass must activate the triangle");
assert_eq!(
world.resource::<ActivationState>().activated_count, 1,
"ActivationState must record the first activation"
);
// Second run — ActivationState blocks it (v0.1 one-shot)
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS * 2;
schedule.run(&mut world);
assert!(
world.resource::<TriangleActivatedQueue>().is_empty(),
"activation must not fire a second time (v0.1 one-shot)"
);
}
#[test]
fn compute_engagement_score_formula() {
let record = EngagementRecord {
observation_time_ticks: 200,
conversation_count: 3,
monologue_trigger_count: 1,
};
let score = compute_engagement_score(&record);
// 3 * 2.0 + 200 * 0.005 + 1 * 8.0 = 6.0 + 1.0 + 8.0 = 15.0
assert!((score - 15.0_f32).abs() < 0.001, "score={score}");
}
#[test]
fn compute_engagement_score_caps_conversations() {
let record = EngagementRecord {
observation_time_ticks: 0,
conversation_count: 100, // way above CONVERSATION_CAP = 5
monologue_trigger_count: 0,
};
let score = compute_engagement_score(&record);
// capped at 5 * 2.0 = 10.0
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
}
}
+1
View File
@@ -74,6 +74,7 @@ fn snapshot_roundtrip_over_unix_socket() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
+1
View File
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_tcp() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
@@ -420,6 +420,8 @@ fn minimal_save() -> SaveStateV1 {
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
}
}
+1
View File
@@ -295,6 +295,7 @@ fn snapshot_with_sim_errors_roundtrips() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: Some(0xDEADBEEF),
debug_response: None,
sim_errors: vec![
SimError {
kind: SimErrorKind::ProtocolError,
+3
View File
@@ -50,6 +50,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
}
}
@@ -244,6 +245,7 @@ fn generate_msgpack_fixtures() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
write_fixture(
@@ -408,6 +410,7 @@ fn generate_msgpack_fixtures() {
}),
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
write_fixture(
+185
View File
@@ -0,0 +1,185 @@
//! Generate snapshot fixtures from the Gauntlet test world (D-030 full pipeline).
//!
//! Runs the full Gauntlet simulation pipeline — same code path as --test-mode —
//! then serializes ObserverSnapshots to both MessagePack (wire format) and JSON
//! (human-readable debug) for client-side visual testing.
//!
//! The .msgpack files are the actual wire-format bytes the server sends over IPC.
//! Client visual tests load them via Protocol.decode_snapshot() → GameState.apply_snapshot(),
//! exercising the exact same pipeline as the live game.
//!
//! The .json files are for human inspection only.
//!
//! Run with: cargo test --test gen_gauntlet_fixtures -- --ignored
//! Or: make fixtures-gauntlet
//!
//! Output: tests/fixtures/gauntlet/*.{msgpack,json} (relative to repo root)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::npc::NpcPlugin;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition};
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::SimulationPlugin;
use settled_reach_server::test_world;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
const SEED: u64 = 42;
const FIXTURE_DIR: &str = "../tests/fixtures/gauntlet";
/// Build a deterministic Gauntlet simulation app.
/// Identical to what the server runs in --test-mode.
fn build_gauntlet(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(&mut app);
app
}
/// Teleport the player to a specific position and facing.
/// Directly modifies ECS components — same effect as PlayerAction::Teleport
/// but without needing a target room action.
fn teleport_player(app: &mut App, pos: TilePosition, facing: Facing) {
let player = {
let mut query = app
.world_mut()
.query_filtered::<Entity, With<PlayerCharacter>>();
query.single(app.world()).expect("player entity must exist")
};
app.world_mut()
.entity_mut(player)
.insert((pos, facing));
}
/// Run N ticks, feeding inputs each tick, return the last snapshot.
fn run_ticks(app: &mut App, inputs: &[Vec<PlayerInput>]) -> ObserverSnapshot {
let mut last_snapshot: Option<ObserverSnapshot> = None;
for tick_inputs in inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
last_snapshot = Some(snapshot.clone());
}
}
last_snapshot.expect("no snapshot produced")
}
/// Recursively sort all object keys for deterministic JSON output.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
fn write_fixture(name: &str, snapshot: &ObserverSnapshot) {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR);
fs::create_dir_all(&dir).expect("create fixture dir");
// MessagePack — exact wire-format bytes the server sends over IPC.
// Client visual tests load these via Protocol.decode_snapshot().
let msgpack = rmp_serde::to_vec_named(snapshot).expect("serialize to MessagePack");
let msgpack_path = dir.join(format!("{}.msgpack", name));
fs::write(&msgpack_path, &msgpack).expect("write msgpack fixture");
// JSON — human-readable debug companion (not loaded by tests).
let value: Value = serde_json::to_value(snapshot).expect("serialize to JSON");
let sorted = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted).expect("format JSON") + "\n";
let json_path = dir.join(format!("{}.json", name));
fs::write(&json_path, &json).expect("write json fixture");
eprintln!(
"Wrote {} ({} bytes msgpack, {} bytes json, {} visible_tiles, {} entities)",
name,
msgpack.len(),
json.len(),
snapshot.visible_tiles.len(),
snapshot.entities.len(),
);
}
#[test]
#[ignore] // Run manually: cargo test --test gen_gauntlet_fixtures -- --ignored
fn generate_gauntlet_snapshot_fixtures() {
// --- Hub (default spawn position) ---
// Player at (50, 58) facing North — tests fog rendering at the starting location.
// This is the exact state a new player sees on connect.
{
let mut app = build_gauntlet(SEED);
// Run 3 idle ticks to stabilize (cognitive delay, vision cone init)
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_spawn", &snapshot);
}
// --- Fog Theater (observer position) ---
// Player at (56, 18) facing South — large open room with NPCs at varying distances.
// Tests visibility cone, fog layers, and distance-based fog rendering.
{
let mut app = build_gauntlet(SEED);
let fog_theater = test_world::constants::FOG_THEATER;
teleport_player(&mut app, fog_theater.observer, fog_theater.observer_facing);
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("fog_theater", &snapshot);
}
// --- Hub after movement (explored tiles + visible tiles differ) ---
// Player moves south from hub, creating a mix of explored-but-not-visible
// and currently-visible tiles — the exact fog boundary condition.
{
let mut app = build_gauntlet(SEED);
let inputs: Vec<Vec<PlayerInput>> = vec![
vec![],
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 2,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveSouth,
}],
vec![], // idle — snapshot has explored + visible tiles that differ
];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_after_movement", &snapshot);
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -208,6 +208,8 @@ fn save_state_npc_kg_isolation() {
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
};
// Roundtrip: serialize → deserialize.
+4 -1
View File
@@ -38,6 +38,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
}
}
@@ -299,6 +300,7 @@ fn snapshot_v2_fields_roundtrip() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
@@ -354,7 +356,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 17,
PROTOCOL_VERSION, 18,
"bump this assertion when protocol version changes"
);
}
@@ -406,6 +408,7 @@ fn all_facing_direction_variants_roundtrip() {
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");

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