Compare commits

...
205 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
jpmschweitzerandClaude Opus 4.6 7231b28850 chore(meta): release v0.1.22
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:25:31 +01:00
jpmschweitzerandClaude Opus 4.6 5aea6e283b chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:34:17 +01:00
jpmschweitzerandClaude Opus 4.6 6bcdc48412 feat(client): add visual test harness with golden regression
Gives Claude eyes: `make screenshot` captures a rendered frame,
`make test-visual` compares against golden PNGs, `make visual-update`
regenerates goldens. Built to debug the Sprint 22 fog regression and
prevent future visual regressions across fog, HUD, dialogue, and UI.

Config-driven via tests/visual.json (11 scenarios, 2 flows).
Capture engine boots main.tscn with real GPU rendering (not --headless),
waits for NoiseTexture2D async gen, uses deterministic shader time.

Components:
- visual_capture.gd: SceneTree capture engine (scenario + movie modes)
- visual_scenarios.gd: per-scenario setup hooks
- tooling/visual-diff: pixel comparator (PIL primary, struct fallback)
- tooling/visual-thumbnail: contact sheet + crop tool
- tests/run-visual: suite script (xvfb wrapping, golden workflow)
- fog_state.gd: override_time for deterministic captures
- fog_shader.gd: fog_noise_ready signal for settle sequencing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:33:55 +01:00
jpmschweitzerandClaude Opus 4.6 8ac904fe69 fix(client): remove return statements from fog fragment shader
Godot 4.6 OpenGL3 compatibility mode does not support 'return' in
fragment(). The early returns on lines 68 and 82 caused silent shader
compilation failure, making the fog overlay render as a no-op — the
root cause of the Sprint 22 fog regression.

Restructured to if/else-if/else chain preserving identical logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:33:33 +01:00
jpmschweitzerandClaude Opus 4.6 6d416d144f fix(client): simplify fog shader to match D-015 — light fog only, blur exploration edge
The 5-layer fog model's deep fog sub-zone (alpha 0.55-0.70) was designed
for the old peripheral sector. After #569 removed peripheral, tiles behind
the player dropped straight to deep fog — indistinguishable from unexplored
black over dark scene tiles.

D-015 is explicit: behind = light fog overlay, art preserved, just "not
fresh." There is no deep fog sub-zone.

Changes:
- Remove dual light/deep fog alpha — all explored tiles use alpha 0.25-0.35
- Add 5x5 Gaussian blur on exploration texture to soften staircase boundary
- Use blurred exploration value for the visual transition (raw for anti-bleed)
- Remove DARK_OVERLAY constant (zone_tint handles all explored color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:22:01 +01:00
jpmschweitzerandClaude Opus 4.6 95875cb92d fix(client): restore tiles variable for zone tint loop in fog_state
The merge of visual and client branches dropped the `tiles` variable
declaration. The client branch refactored bounds calculation to use
`visible_positions`, but the visual branch's zone tint loop still
iterates over `visible_tiles` for zone_id data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:04:22 +01:00
jpmschweitzer 24b9a7a0cc Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-28 23:55:42 +01:00
jpmschweitzerandClaude Opus 4.6 282dad8d50 fix(simulation): address all round 3 PR review issues
- Register StorytellerPlugin in main() and dump_schedule_graph() so
  contamination system runs in production (critical, rounds 2+3)
- Add doc comment to z_bands_connected clarifying band indices vs
  absolute z-levels (D-110)
- Add TODO on hardcoded modifications: vec![] in save_to_file
- Init ContaminationActive in minimal_world() test helper
- Replace ChaCha20Rng with SimRng in fuzzy_map tests (D-010)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:48:03 +01:00
jpmschweitzer f80b6c1aca Merge remote-tracking branch 'origin/visual'
# Conflicts:
#	CHANGELOG.md
#	client/scripts/autoloads/fog_state.gd
#	client/shaders/fog.gdshader
2026-02-28 23:43:26 +01:00
jpmschweitzerandClaude Opus 4.6 12c2d86771 fix(assets): deprecate VIS_PERIPHERAL and fix spec table label
Round 4 review: mark VIS_PERIPHERAL as deprecated (peripheral sector
removed in #569, constant retained for test compatibility). Fix spec
status table row from "peripheral sector" to "cone gradient".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:38:29 +01:00
jpmschweitzer 150faca5d9 Merge remote-tracking branch 'origin/client' 2026-02-28 23:34:36 +01:00
jpmschweitzerandClaude Opus 4.6 2f22fbe4c1 fix(assets): purge stale 5-layer/peripheral references from fog spec
Round 3 review fixes — thorough spec cleanup:
- Remove visibility_sectors from data flow (peripheral removed in #569)
- Remove player_pos uniform (cone center implicit in visibility_tex)
- Update FogState pseudocode: remove sector step, add zone tint step
- Update lifecycle diagram to match single update_from_state() call
- Fix "5-layer fog" → "3-state fog" in Files to Create and impl notes
- Mark zone tint open question as resolved (Sprint 22, D-077)
- Document filter_nearest rationale on zone_tint_tex (D-073 hard zones)
- Note low-saturation tint is intentional per D-046 Hopper test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:26:30 +01:00
jpmschweitzerandClaude Opus 4.6 d3217c5ae3 fix(simulation): replace f32 fields in generator with integer types
BlockPlacement.street_width_factor (f32) → street_width_bps (u16,
basis points where 10000 = 1.0×). BlockSkeleton.density (f32) →
density_pct (u8, 0–100 percentage). Eliminates latent f32
non-determinism per D-010 principle 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:25:02 +01:00
jpmschweitzerandClaude Opus 4.6 339f112c8f fix(simulation): fix triangle state save/load and persist contamination
Three save/load bugs fixed:
- ContaminationActive not persisted in SaveStateV1 — caused double-fire
  of contamination pressure on reload after tick 300.
- Loaded triangle entities missing ActiveSim marker — made them
  invisible to escalation and contamination systems after any load.
- Existing triangle entities not despawned before load — created
  duplicates, doubling tension escalation per tick.

Also: HashSet → BTreeSet for D-010 compliance, defensive event queue
reset on load, and three regression tests for triangle roundtrip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:56 +01:00
jpmschweitzerandClaude Opus 4.6 70de959ac3 fix(simulation): correct CONTAMINATION_DELAY_TICKS from 1800 to 300
The constant was supposed to represent 30 game-minutes but the formula
was wrong (30 × 10 tps × 60s = 1800). Correct derivation: 30 minutes ×
TICKS_PER_GAME_MINUTE (10) = 300. Now uses the canonical constant
directly. Also fixes stale assertion message in integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:47 +01:00
jpmschweitzerandClaude Opus 4.6 6fac224d55 fix(client): prevent signed overflow in world_seed generation and load
GDScript int is i64 — when randi() returns a value with bit 31 set,
left-shifting by 32 sets bit 63, producing a negative i64. MessagePack
encodes this as a negative integer, which Rust rmp_serde rejects when
deserializing as u64, causing ~50% startup failure rate.

Fix: mask bit 31 before shifting in new_game() to cap entropy at 63
bits. Also mask the sign bit in _read_seed_file() to handle save files
written before this fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:46 +01:00
jpmschweitzerandClaude Opus 4.6 a1bdab949b fix(assets): sync spec noise amplitudes and gradient radius
GLSL sample comments now say ±0.05 / ±0.075 matching the code and
spec table. Implementation notes gradient radius updated from 6-8
to 3-4 tiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:21:10 +01:00
jpmschweitzerandClaude Opus 4.6 ded24de31b chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:42 +01:00
jpmschweitzerandClaude Opus 4.6 b025fe8152 chore(db): remove db/connectors backwards-compat symlink (#568)
Remove the db/connectors → tooling/db/ symlink added in Sprint 21
(#274) and migrate all references to use tooling/db/ directly.

- Delete tracked symlink from db/connectors
- Remove duplicate db/connectors/* permission patterns from settings
- Update project-structure.md to reflect removal
- Move whatsinagame/static/db/connectors/ to whatsinagame/static/tooling/db/
- Update 20 whatsinagame template, skill, and test files
- Update comment references in client/tests/test_anti_tedium.gd
- Historical docs (old sprint briefings, changelog, discussions) left as-is

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:26 +01:00
jpmschweitzerandClaude Opus 4.6 b9b9c923a0 fix(assets): correct gradient radius comment from 6-8 to 3-4 tiles
The 7x7 Gaussian kernel (sigma 2.0) produces a 3-4 tile radius
gradient, not 6-8 tiles. Header comment now matches the function
comment and actual math.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:12:56 +01:00
jpmschweitzerandClaude Opus 4.6 50aba3adf6 fix(client): address PR #78 review comments
- Widen world_seed entropy from u32 to full u64 by combining two randi()
  calls (Hoshe warning #1)
- Persist world_seed to save directory and restore on resume_game() so
  loaded sessions maintain D-010 deterministic replay (Tyre warning #2)
- Constrain EntanglementConfig intrigue range based on flat value so
  mundane_ratio stays within D-029 spec [45,55]% (both reviewers)
- Remove dead VIS_PERIPHERAL constant and _grow_bounds() method
- Update test_client_p1 peripheral test for forward-only simplification
- Fix misleading exp_fade shader comment (filter_nearest = hard step)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:11:17 +01:00
jpmschweitzerandClaude Opus 4.6 552c90a264 fix(assets): review fixes — spec alpha ranges and noise symmetry
Sync fog-shader-spec.md with actual shader values after #563 tuning:
light fog 0.25-0.35 (was 0.26-0.34), deep fog 0.55-0.70 (was 0.54-0.70).
Pseudocode now uses symmetric noise remapping (noise*2-1)*amp to match
the shader. Added first-call guard comment on _tint_bytes in fog_state.gd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:04:10 +01:00
jpmschweitzerandClaude Opus 4.6 d867cf1e3b chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:35:24 +01:00
jpmschweitzerandClaude Opus 4.6 3cd3a998c0 test(simulation): add fuzzy tests for procedural map generation (#509)
50-seed randomized testing against 4 structural invariants:
walkable connectivity (BFS), entity bounds, door adjacency,
and minimum tile count floor. Includes generator module for
test-scoped procedural map creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:58 +01:00
jpmschweitzerandClaude Opus 4.6 b126e1830d feat(simulation): add contamination activation mechanic (#254)
Timer-based storyteller system fires after 1800 ticks (30 game-min).
Sets ContaminationActive resource, applies tension delta to all
ActiveFork triangles, and emits ContaminationEvent for downstream
monologue/behavioral hooks. Q-017 fallback constants in place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:47 +01:00
jpmschweitzerandClaude Opus 4.6 42febf4059 feat(simulation): wire NPC pool generation and authored triangle instantiation (#176, #188)
Production startup now spawns 23 Sova NPCs with EntanglementTag
(Flat/Intrigue) based on triangle_membership. Three-phase spawn:
entity creation, cross-reference resolution, and authored triangle
instantiation. Five triangles (3 ActiveFork, 2 PassiveTension per
D-087) with deterministic IDs via FNV-1a hashing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:36 +01:00
jpmschweitzerandClaude Opus 4.6 daae3dd6ab feat(simulation): add Zone Gate gauntlet room and zone crossing detection (#512)
New test_world room with two zones (Terminal/Corridor) separated by
a door. Adds ZoneCrossEventQueue resource and detect_zone_crossings
system to fire events when the player crosses zone boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:21 +01:00
jpmschweitzerandClaude Opus 4.6 61eb40fcab feat(simulation): add modifications data model stub (#567, D-112)
DLC entry point for future player construction system. Adds
Modification struct, ModificationType enum, and Modifications
component. Wired into SaveStateV1 with #[serde(default)] for
forward-compatible save format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:08 +01:00
jpmschweitzerandClaude Opus 4.6 b89c4d5c9d chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:26:58 +01:00
jpmschweitzerandClaude Opus 4.6 d591f44b35 feat(simulation): add world_seed IPC and EntanglementConfig (#175, #178)
Implements the StartupMessage protocol: client generates world_seed in
SessionManager.new_game(), sends it after handshake, server uses it to
seed SimRng and sample EntanglementConfig.

EntanglementConfig samples flat ∈ [25,35]%, intrigue ∈ [15,25]%, mundane
as remainder (D-029). Same seed produces identical config (D-010
determinism). Different seeds produce distinct configs in ≥90% of pairs.

Protocol flow: HandshakeMessage (server→client) → StartupMessage with
world_seed (client→server) → SimRng initialization → tick loop.

10 Rust tests (determinism, variation, bounds, sum invariant).
9 GDScript test stubs + 2 encode tests for client-side pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:26:31 +01:00
jpmschweitzerandClaude Opus 4.6 ebc87d8e74 fix(client): fix fog system — blocky edges and zero explored visibility (#569)
Root cause: update_from_state() used visible_tiles (always empty in live
server mode) instead of visible_positions for bounds calculation. Bounds
never grew beyond 64x64, so tiles outside that area rendered as solid
unexplored black.

Fix: new _grow_bounds_from_positions() method reads visible_positions
(always populated from server snapshots). Shader fix: removed the
(explored < 0.01 && vis_raw < 0.01) guard that cut off the Gaussian
gradient at unexplored tile boundaries. Doubled blur step size for
D-066 compliant 6-8 tile soft gradient. Added debug_exploration mode
for diagnostic rendering of the exploration texture.

19 acceptance tests covering exploration persistence, bounds grow-only
invariant, gradient margin, Forward-only visibility writes, and
exploration data surviving texture resize.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:26:13 +01:00
jpmschweitzerandClaude Opus 4.6 04c2eb362a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:12:16 +01:00
jpmschweitzerandClaude Opus 4.6 dddfceeb5a fix(assets): tune fog shader alpha and add zone temperature tint per D-059
Ticket #563. Light fog alpha tuned to 0.25-0.35 range (was 0.25-0.55),
deep fog alpha set to 0.55-0.70 with zone temperature tint from
zone_tint_tex (bar=warm #2a1f15, hub=cool #1a1f2e, corridor=neutral
#1a1a1a). Two Perlin noise cycles: 8-10s light, 15-20s deep.
Zone tint texture now populated per-tile from server zone_id in
fog_state.gd with preservation across texture resizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:11:56 +01:00
jpmschweitzerandClaude Opus 4.6 073c5a317c chore(db): update database backup after planning merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:50:43 +01:00
jpmschweitzerandClaude Opus 4.6 f63691069c docs(docs): split questions.md into per-domain files
Mirror the D-record pattern: questions.md becomes an index,
full question content moves to questions-architecture.md,
questions-perception.md, questions-content.md, questions-scope.md.

Also incorporates final Sprint 22 team findings into Q-053/Q-054:
- Q-053: transit map as incidental discovery surface, confidence
  signal, boards as entitlement map (Paula round 3)
- Q-054: stateless DiagramData renderer architecture, annotation
  event model (Gestalt round 3)

Corrects question counts: 16 resolved, 4 partially resolved,
34 open (previously undercounted).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:25:22 +01:00
jpmschweitzerandClaude Opus 4.6 9b8569a03a docs(docs): Sprint 22 planning — Q-052/Q-053/Q-054, D-108 amendment
- Q-052: Storyteller hint delivery — parallel diegetic channel model
  with 8 ranked channels, scaling properties, signal pollution gating
- Q-053: Insert workspace boards — design philosophy and information
  architecture (general-purpose communication layer, not mechanic)
- Q-054: Insert workspace board — rendering primitive data contract
  with 6 open technical questions
- D-108: Amended with MobileChunk Idle state note and D-111 cross-ref
- Ticket #162 scoped for Sprint 23 (storyteller module activation)
- Ticket #566 done (D-108 documentation update)
- Tickets #570-572 created (EngagementRecord, MovementHistoryBuffer,
  triangle lifecycle rules)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:15:32 +01:00
jpmschweitzerandClaude Opus 4.6 1ead61ce99 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:20:21 +01:00
jpmschweitzerandClaude Opus 4.6 a862426f84 docs(docs): add Sprint 22 briefings and track Godot auto-generated files
Sprint 22 "Wire" briefings for server, client, visual, CI, and
planning teams. Also track Godot .import and .uid files that were
previously untracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:20:02 +01:00
jpmschweitzerandClaude Opus 4.6 d4f81e9373 docs(docs): update D-015/D-017 for simplified cone model, add Q-051
Update perception decisions to reflect the forward-only 120° cone
(no peripheral sector). Add Q-051: speech bubble indicator over
speaking NPCs for dialogue attribution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:19:48 +01:00
jpmschweitzerandClaude Opus 4.6 1edc8bb1ec refactor(client): simplify fog shader to 3-layer model
Reduce from 5-layer to 3-layer fog: clear (forward cone), explored
(light overlay preserving art), and unexplored (solid near-black).
Remove peripheral sector handling from fog_state.gd.

Also preserve exploration data across texture resizes — previously,
resizing the fog texture lost all explored-tile state, causing tiles
behind the player to render as unexplored black instead of light fog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:19:44 +01:00
jpmschweitzerandClaude Opus 4.6 2016e9a725 refactor(simulation): simplify vision cone to forward-only 120° arc
Remove the peripheral sector (100° half-angle, reduced range) and blind
spot classification. The server now sends only tiles within the 120°
forward cone; the client renders previously-explored tiles behind the
player with a light fog overlay instead.

This eliminates complexity in both the cone classifier and the snapshot
protocol while preserving the core information asymmetry — you still
can't see behind you, and the monologue system (D-016) still bridges
the perceptual gap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:19:38 +01:00
jpmschweitzerandClaude Opus 4.6 0bb38dd3d9 chore(db): update database backup after planning merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:18:47 +01:00
jpmschweitzer 72fb8ff987 Merge remote-tracking branch 'origin/planning' 2026-02-28 12:18:37 +01:00
jpmschweitzerandClaude Opus 4.6 a69e69e663 feat(perception): ground vision cone in human sensory physiology
- Visible half-angle 150° → 100° (200° arc), matching average human
  binocular field. Blind spot widens from 60° to 160° — genuine
  vulnerability behind the character.
- Forward half-angle stays at 60° (120° arc) — binocular overlap zone.
- Peripheral zone represents combined sensory awareness: visual
  periphery + subconscious sound/motion tracking, not just eyes.
- Per-character VisionConeConfig allows implants and perception modes
  (D-017) to widen beyond the unaugmented baseline.
- Fog shader: smooth back-edge gradient via smoothstep blend between
  Layer 2 (peripheral) and Layer 3 (deep fog), eliminating blocky
  stair-stepped tiles at the rear of the vision cone.
- D-015 updated with physiological basis and per-character config.
- D-017 updated with vision cone modification by perception modes.
- D-020 updated with protocol versioning policy: PROTOCOL_VERSION
  gates wire format, not gameplay parameters.
- DB backup after sprint 21 close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:31:29 +01:00
jpmschweitzer dd0de2ff04 Merge remote-tracking branch 'origin/visual' 2026-02-27 21:50:36 +01:00
jpmschweitzerandClaude Opus 4.6 e28be513cd fix(assets): review fixes — fog noise speed and kernel UV clamping
Deep fog noise scroll speed 0.045/0.03 → 0.06/0.045 to match D-059
15-20s breathing cycle spec. Clamp sample_visibility() UV coordinates
to [0,1] to prevent sticky gradient at map edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:48:39 +01:00
jpmschweitzerandClaude Opus 4.6 93c9d2dcb4 fix(client): bump protocol version to 17 after server merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:36:32 +01:00
jpmschweitzerandClaude Opus 4.6 df1d9c8990 fix(assets): add Gaussian blur to fog visibility for soft cone edge
The visibility texture has binary per-tile values (0/180/255) — bilinear
filtering alone only smooths ~1 tile, producing hard stair-stepped edges
at the LOS boundary instead of the 6-8 sim tile gradient D-059 specifies.

Add sample_visibility() with 7x7 Gaussian kernel (sigma 2.0) that
spreads the LOS boundary into a 3-4 tile radius gradient. Lower
PERIPHERAL_LOW from 0.55 to 0.08 so gradient tiles enter the peripheral
fog branch. Fix Layer 1/2 alpha discontinuity at CLEAR_THRESHOLD.
Guard against gradient bleed into unexplored tiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:40:49 +01:00
jpmschweitzerandClaude Opus 4.6 8934a8201d fix(assets): tune fog shader alpha and clean cone edge per D-059
Layer 2 (peripheral): reduce peak alpha 0.55→0.38, noise 0.1→0.05,
taper noise to zero near clear boundary for a clean gradient edge.
Layer 3 (deep fog): reduce alpha range 0.78-0.90→0.62-0.76 so zone
temperature tint breathes through. Layer 5 unchanged (alpha 1.0).

Ticket: #564

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:33:47 +01:00
jpmschweitzer bb91b0acf0 Merge remote-tracking branch 'origin/main' into visual
# Conflicts:
#	docs/design/wireframes/menus/v01-save-load.png
2026-02-27 19:29:17 +01:00
jpmschweitzer d170362d1f Merge remote-tracking branch 'origin/server' 2026-02-27 19:24:42 +01:00
jpmschweitzerandClaude Opus 4.6 dd00a9cd9e fix(simulation): address PR #75 review — vision components, triangle validation, FNV-1a, KG gating
1. CRITICAL: spawn_template_npcs now inserts NpcVisionState, NpcMemory,
   PlayerAwareness on template-spawned NPCs — matches spawn_npc() pattern
   from PR #66. Without these, template NPCs were invisible to vision and
   awareness systems.

2. WARNING: generate_intra_template_triangles now calls validate_triangle_def
   before generating TriangleState — mirrors cross-template path. Updates
   test TriangleDefs and logistics-hub.yaml to pass all three quality checks
   (conflict viability, relationship coherence, interest divergence).

3. WARNING: state_hash in compute_observer_snapshot now uses FNV-1a instead
   of DefaultHasher — consistent with D-010 principle 4 and the pattern in
   TemplateId/TriangleId. Updates golden file for new hash value.

4. WARNING: TriangleCrisisEventWire.role_assignments now filtered against
   observer KnowledgeGraph — unknown NPCs redacted from wire event per
   D-010 principle 2 (information boundaries).

5. WARNING: ActiveTemplateInstances::insert now despawns previous instance
   entities before overwriting — prevents orphaned ECS entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:02:43 +01:00
jpmschweitzer b47d53f6e5 Merge remote-tracking branch 'origin/ci' 2026-02-27 18:57:06 +01:00
jpmschweitzerandClaude Opus 4.6 394742ee98 fix(ci): add missing db/connectors/audio-batch permission
Parity fix from PR #74 re-review round 2: legacy permission block
was missing audio-batch entry that exists in the tooling/db/ block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:54:21 +01:00
jpmschweitzerandClaude Opus 4.6 64bf4ec539 fix(client): address PR #73 review — race conditions and defensive guards (#257)
- Defer LOAD_GAME dispatch until SimBridge reaches CONNECTED (critical)
- Guard _build_saves_list() against queue_free() race on rapid reopen
- Disable save entries with empty newest_save, guard in _on_save_selected
- Send before show_loading on F6 quickload, skip overlay on send failure
- Clear pending_load_path in _on_new_game()/_on_continue() (stale path)
- Add hide_loading(success: bool) API for future failure-state UI
- Add test_save_load_flow_sprint21.gd covering LoadingScreen + GameState

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:06:16 +01:00
jpmschweitzerandClaude Opus 4.6 88fc6c30dc feat(simulation): template instantiation engine — load, spawn, lifecycle (#161)
Add content::instantiation module with:
- instantiate_template(): validates FullTemplateDef, calls
  spawn_template_npcs, generates TriangleState entities with ActiveSim,
  registers in ActiveTemplateInstances resource
- unload_template(): despawns all NPC + triangle entities, removes
  from tracking
- load_template_from_file(): YAML → FullTemplateDef deserialization
- ActiveTemplateInstances: BTreeMap-backed resource (D-010 determinism)

Integration tests: end-to-end logistics-hub.yaml instantiation (4 NPCs,
2 TriangleStates), lifecycle (instantiate → unload → clean), determinism
(same seed = same layout), error path (missing file).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:59:49 +01:00
jpmschweitzerandClaude Opus 4.6 b6340f9663 fix(simulation): clean up warnings in spawn and template modules (#166)
Remove duplicate #[test] attribute, unused TemplateId import, and dead
spawn_escalation_npc helper function (no longer referenced after #250
refactor).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:59:38 +01:00
jpmschweitzerandClaude Opus 4.6 ccff595101 fix(ci): address PR #74 review comments
Add missing db/connectors/decision permission in settings.json and
update DEVOPS.md layout table to reflect connector move. Filed #568
for Sprint 22 symlink removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:51:09 +01:00
jpmschweitzerandClaude Opus 4.6 ace5cb811f wip(simulation): template-to-instance mapping — spawn_template_npcs (#166)
Add spawn_template_npcs: three-phase template instantiation (spawn NPCs
per role slot, wire intra-template relationships, record cross-template
references in TemplateReferenceMap). Partially complete — needs
validation pass, error handling, and integration with content loading
pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:46:47 +01:00
jpmschweitzerandClaude Opus 4.6 6f39df884c feat(simulation): tier 2 template definition format — FullTemplateDef with tests (#159)
Add FullTemplateDef integration tests: round-trip YAML serialization,
space spec validation, routine schedule, sightline zones, dialogue pool
refs, cross-template link specs, and logistics-hub template fixture.
Tests cover the full social site template document structure per D-023,
D-024, D-025, D-028.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:46:37 +01:00
jpmschweitzerandClaude Opus 4.6 d31ac1cc70 feat(simulation): error handling and recovery — panic supervision, state hash, structured errors (#85)
Protocol v17: add state_hash (desync detection) and sim_errors
(structured error reporting) to ObserverSnapshot. Add SimError,
SimErrorKind, SimErrorBuffer types. Wrap main loop app.update() in
catch_unwind — on panic, send a final SimError snapshot before exit.
Report recoverable deserialization errors to client via SimErrorBuffer.
Compute per-tick state hash from player position + NPC count + tick.
Update all test fixtures and golden files for protocol v17.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:46:27 +01:00
jpmschweitzerandClaude Opus 4.6 c2cae1f618 fix(simulation): register interaction systems and persist door state (#246)
Follow-up to b6a9b78: register TerminalInteractedQueue resource and
door/terminal interaction systems in SimulationPlugin; add door state
save/load in save_io (open doors round-trip through SaveStateV1);
make WalkabilityMap param optional in process_door_interaction so
plugin-only tests work without a loaded map; fix information_boundaries
test missing open_doors field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:46:15 +01:00
jpmschweitzerandClaude Sonnet 4.6 b6a9b78205 feat(simulation): basic environmental interaction — doors, examine, terminals (#246)
- Add DoorState component tracking is_open and blocking_tile; add
  DoorInteractRequest per-player component consumed by new
  process_door_interaction system (toggles walkability each use)
- Add TerminalInteracted event, TerminalInteractedQueue resource,
  TerminalInteractRequest component, and process_terminal_interaction
  system (emits event on Use verb)
- Add ExamineText(String) component for authored object examine text;
  extend process_examine_interaction with object examine path:
  uses ExamineText if present, falls back to generic string if absent
- Fix: add Without<ObjectType> filter to npc_query in
  process_examine_interaction — previously any entity with TilePosition
  was mis-routed through the NPC text generator
- Add SaveStateV1.open_doors: Vec<StableId> with #[serde(default)]
  for backward-compatible serialization
- Add "Open"/"Close" → DoorInteractRequest and "Use" →
  TerminalInteractRequest dispatch in process_player_input
- 10 integration tests in tests/environmental_interaction.rs covering
  all acceptance criteria: door toggle (both directions), open-to-close,
  invalid target, readable examine (with/without ExamineText), out-of-range,
  terminal event emission, request cleanup, and save state round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 11:11:45 +01:00
jpmschweitzerandClaude Sonnet 4.6 c4e09bff77 feat(simulation): implement triangle validation and cross-template generation (#108, #109)
- Add ValidationError enum with three failure modes: ConflictViability
  (missing Want axis), RelationshipCoherence (empty constraints),
  InterestDivergence (duplicate interest axes)
- Add validate_triangle_def() pure function enforcing all three checks
  in priority order (per D-087)
- Add generate_cross_template_triangles() function that combines NPC
  pools from two templates, validates each TriangleDef before processing,
  and assigns ownership to template_a
- 10 integration tests in tests/triangle_validation.rs covering all
  validation failure modes, ordering guarantees, cross-template span,
  invalid def skipping, determinism, and intra-template isolation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 11:11:31 +01:00
jpmschweitzerandClaude Opus 4.6 0ab81961dc chore(meta): sync Cargo.lock version after main merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:09:39 +01:00
jpmschweitzerandClaude Opus 4.6 2893366a6f chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:08:35 +01:00
jpmschweitzerandClaude Opus 4.6 bc226d8baf chore(db): move db/connectors/ to tooling/db/ (#274)
Consolidates all connector scripts under tooling/ per project
structure conventions. Symlink at db/connectors → tooling/db/
preserves backwards compatibility (remove after Sprint 22).

Updated references in CLAUDE.md, Makefile, DEVOPS.md, all skill
files, agent files, rules, schema comments, and Sprint 21
briefings. Python scripts updated with correct SCHEMA_PATH
(now relative to WORKTREE_ROOT/db/schema.sql).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:08:00 +01:00
jpmschweitzerandClaude Opus 4.6 a44a1081bc docs(docs): record D-110–D-112 — z-level addressing, subterranean architecture, no instancing
Signed z-level fix (base_z u8→i8), MobileChunk Idle as stationary
installation primitive, and rejection of separate location instancing.
LocalOverlay confirmed as canonical DLC/mod content injection point.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:07:13 +01:00
jpmschweitzerandClaude Opus 4.6 c797869503 feat(client): add save/load game flow and move debug_overlay (#257, #561)
#257: Add Load Game screen to main menu with sorted save list, loading
overlay during quickload round-trip, and pending_load_path cross-scene
flow. F5/F6 quicksave/quickload were already wired.

#561: Move debug_overlay.gd from scripts/ui/ to ui/ for consistency
with all other UI components. Update scene and test references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:07:10 +01:00
jpmschweitzerandClaude Opus 4.6 93e050f3bf chore(db): backup database after sprint 21 merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:46:44 +01:00
jpmschweitzer d8497962c6 Merge branch 'planning' 2026-02-27 10:46:33 +01:00
jpmschweitzer c69290e7a0 Merge branch 'maintenance' 2026-02-27 10:46:15 +01:00
jpmschweitzerandClaude Opus 4.6 2f2f8eb06d docs(docs): file D-096–D-109 and Q-045–Q-050 from generator workshop
14 confirmed decisions filed across architecture.md (10) and content.md (4).
6 open questions filed in questions.md (Q-046 resolved immediately by D-108).
README.md index updated.

Key decisions: DistrictLayoutMode (D-096), guarantee tier system (D-097),
MobileChunk spec (D-108), DamageOverlay prohibition (D-109), WorldTier
enum with corrected constraint ceilings, heritage grammar overlay (D-104).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:40:15 +01:00
jpmschweitzerandClaude Opus 4.6 bfbe0d1b44 docs(docs): generator architecture workshop — 5 rounds, 14 D-records
Complete workshop output for ticket #562. Establishes the procedural
generator pipeline architecture for the 300-world model: two-phase
generation (DistrictSkeleton + ChunkData), three-layer model
(generator/simulation/delta), spatial hierarchy, MobileChunk spec,
guarantee tier system, heritage grammar overlay, and damage overlay.

Participants: Gestalt, Tyre, Miri, Araminta, Nigel, Ozzie.
Rounds 1-4 (design) + Round 5 (review/corrections).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:40:02 +01:00
jpmschweitzerandClaude Opus 4.6 9332447c33 chore(sprints): add sprint 21 "Instantiate" briefings
12 tickets across 5 teams: server (7), client (2), visual (1),
ci (1), planning (1). Template instantiation pipeline, cross-template
triangles, save/load flow, fog shader tuning, generator workshop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:31:45 +01:00
jpmschweitzerandClaude Opus 4.6 9b6a3576a2 fix(meta): update paths after projects dir relocation
Replaced /var/home/jeroenschweitzer/Projects/ with /var/mnt/data/projects/
across 13 files (skills, docs, workshops, discussions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:49:24 +01:00
jpmschweitzerandClaude Opus 4.6 be72cccb2a fix(client): bump protocol version to 16
Match server v16 protocol (triangle crisis events on ObserverSnapshot).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:53:37 +01:00
jpmschweitzerandClaude Opus 4.6 615c3a9c17 chore(meta): release v0.1.20
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:52:38 +01:00
jpmschweitzerandClaude Opus 4.6 c8a0f1b327 chore(db): backup database after sprint 20 merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:51:51 +01:00
jpmschweitzer 3c634a937d Merge remote-tracking branch 'origin/server' 2026-02-25 23:50:56 +01:00
jpmschweitzerandClaude Opus 4.6 4ef1157b79 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:35 +01:00
jpmschweitzerandClaude Opus 4.6 d808d95659 refactor(simulation): address PR #72 review suggestions
- Hoshe #3: replace O(n²) Vec scan in fallback NPC assignment with
  BTreeSet; prevent same NPC assigned to two roles in one triangle
- Hoshe #4: add From impls for RoleId, TriangleId, StableId, and
  TriangleCrisisEventWire — eliminate fragile .0 access on newtypes
- Hoshe #5: consolidate near-identical unit tests with integration
  counterparts — keep only unique tests in #[cfg(test)] module
- Tyre #3: replace O(N*M) scan in apply_resolve_triangle with
  BTreeMap<TriangleId, Entity> index for O(1) per-command lookup
- Tyre #4: document &mut World on generate_intra_template_triangles
- Observer snapshot uses TriangleCrisisEventWire::from instead of
  manual field mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:23 +01:00
jpmschweitzerandClaude Opus 4.6 3a555a2eeb fix(simulation): validate dangling with_role in TriangleDef constraints
TriangleDef.validate() now rejects relationship constraints where
with_role references a role not in the triangle's three roles.
Catches authoring errors at load time instead of silently producing
broken constraint data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:23 +01:00
jpmschweitzerandClaude Opus 4.6 1ea3d9c724 fix(simulation): persist TriangleState in SaveStateV1
Triangle phase and tension were silently lost on save/load. Now
serialized as triangle_states vec in SaveStateV1, sorted by
triangle_id for determinism (D-010). Dedicated triangle entities
are respawned on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:18 +01:00
jpmschweitzerandClaude Opus 4.6 9bc5925220 fix(simulation): wire triangle crisis event queue into observer snapshot
The TriangleCrisisEventQueue was populated by tick_triangle_escalation
but never drained into ObserverSnapshot — clients always saw an empty
vec despite protocol v16 advertising the field. Now drains the queue
each tick and converts to TriangleCrisisEventWire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:11 +01:00
jpmschweitzerandClaude Opus 4.6 d34fc970a7 feat(simulation): add triangle escalation integration tests
16 tests covering escalation timing, crisis event emission,
resolve command, D-087 seed-dependent timing, D-089 no-cascade,
and edge cases (dormant skip, saturation, idempotent resolve).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:27 +01:00
jpmschweitzerandClaude Opus 4.6 918dffc643 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:05 +01:00
jpmschweitzerandClaude Opus 4.6 89158f2f2a chore(simulation): regenerate msgpack fixtures for protocol v16
Updates all client-side msgpack test fixtures and server test
harnesses to include the new triangle_crisis_events field added
in protocol v16.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:32 +01:00
jpmschweitzerandClaude Opus 4.6 c436ae905f feat(simulation): wire triangle escalation and crisis events into protocol
Registers tick_triangle_escalation and apply_resolve_triangle systems
in SimulationPlugin. Adds TriangleCrisisEventWire to ObserverSnapshot
(protocol v16) for future client rendering of triangle crises (#250,
D-087). Observer emits empty vec by default; escalation system will
populate when triangles reach Active phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:21 +01:00
jpmschweitzerandClaude Opus 4.6 9eaca0b5a4 feat(simulation): persist template ownership and references in save state
Adds TemplateOwnership to NpcSaveState and TemplateReferenceMap to
SaveStateV1 so cross-template links survive save/load and tier
eviction (D-025, D-026). Both fields use serde(default) for backward
compatibility with existing saves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:13 +01:00
jpmschweitzerandClaude Opus 4.6 0e8ac56fc1 feat(simulation): add social site template schema types
Implements #163 (RoleSchema), #164 (SpaceSpec), #165 (TemplateOwnership
+ TemplateReferenceMap), #106 (TriangleDef), #107 (intra-template
triangle generation), and #250 (triangle escalation system) as the
foundational Tier 2 template system per D-025.

New content/template module with YAML-deserializable schema types,
ECS components for ownership/triangle state, escalation system
running on game-minute boundaries, and TriangleCrisisEvent emission.
Sample YAML templates at server/data/templates/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:06 +01:00
jpmschweitzerandClaude Opus 4.6 98100da0d0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:23 +01:00
jpmschweitzerandClaude Opus 4.6 de136fc1a5 refactor(client): unify duplicate YAML parsers into YamlParser (#560)
Extract shared YamlParser utility (client/scripts/util/yaml_parser.gd)
with parse() for nested typed dicts and parse_flat() for dotted-key
string format. UIStrings._parse_yaml() and ChecklistEvaluator's inline
parser both delegate to YamlParser, removing ~140 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:05 +01:00
jpmschweitzerandClaude Opus 4.6 964bb9c459 refactor(client): extract SnapshotEventRouter from main.gd (#559)
New SnapshotEventRouter class (46 lines) provides callable-based
snapshot dispatch via register(), register_always(), and dispatch().
main.gd _process() now calls _router.dispatch(snapshot) instead of
15+ inline if-has blocks. Handlers registered in _ready().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:56 +01:00
jpmschweitzerandClaude Opus 4.6 6f9c97ca03 refactor(client): decouple dialogue_box from GameState and AudioManager (#558)
Replace 3 direct GameState.dialogue_active mutations and all
AudioManager.apply_dip/clear_dip calls with signals:
dialogue_state_changed, audio_dip_requested, audio_dip_cleared.
dialogue_box.gd now has zero references to GameState or AudioManager.
main.gd wires coordinator handlers in _ready() (D-020).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:47 +01:00
jpmschweitzerandClaude Opus 4.6 c8de1a0629 refactor(client): make stationary_ticks and zone_id server-authoritative (#557)
apply_snapshot() now reads stationary_ticks and zone_id directly from
the server snapshot when present (D-020 compliance). Client-side
accumulation and tile lookup retained as deprecated fallbacks until
the server populates these fields. Protocol.gd extended with decode
paths and TODO markers for the server team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:38 +01:00
jpmschweitzerandClaude Opus 4.6 95a9b349a0 docs(docs): add district topology diagram
D2 source + PNG render showing all zone connections, access tiers,
z-levels, maintenance corridor routing, and dual entry vectors.
Vertical layout with color-coded access tiers per D-093.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:34 +01:00
jpmschweitzerandClaude Opus 4.6 e4a3e0ce60 docs(docs): add gate cluster spatial layout
Full tile-level layout doc matching Terminal and Bar format. 7 zones
across z=1 and z=2, sightline matrix, access tier map, NPC traffic
density, Triangle 5 narrative notes. Per D-093 gate cluster spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:22 +01:00
jpmschweitzerandClaude Opus 4.6 33e0c51218 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:09:11 +01:00
jpmschweitzerandClaude Opus 4.6 1b5825cf39 docs(docs): add generator architecture workshop brief
Workshop brief for ticket #562 covering the top-down district
generator pipeline (Cities Skylines model). Builds on D-094
chunk/block/district hierarchy. Targets Q-036 and Q-037 resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:40 +01:00
jpmschweitzerandClaude Opus 4.6 5c029cf6ea docs(docs): update sova station profile for horizon station lore
Corrects horizon gate description per D-095: gates are at the Krenn
Ring (800 AU orbital installation), not on Station Sova. Admin Hub
houses transit processing facility, not gate apertures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:35 +01:00
jpmschweitzerandClaude Opus 4.6 98d28b5a54 docs(discussions): archive workshop #153 station district layout
Three-round workshop producing D-093 (Sova Transit District spatial
layout), D-094 (chunk/block/district hierarchy), D-095 (horizon
stations and transport lore). Resolves Q-040, Q-041, Q-043, Q-044;
partially resolves Q-042. Establishes spatial hierarchy as
architectural precedent for Q-036 generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:29 +01:00
jpmschweitzerandClaude Opus 4.6 81b64b3cf7 chore(db): backup database after Sprint 19 close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:58:41 +01:00
jpmschweitzer 7d501c2c58 Merge remote-tracking branch 'origin/planning'
# Conflicts:
#	CHANGELOG.md
2026-02-25 16:58:30 +01:00
jpmschweitzerandClaude Opus 4.6 32bf807308 chore(meta): release v0.1.19
Sprint 19: Persist — save/load, tier eviction/scope, test infra,
protocol handshake, IPC fixtures/benchmark. 15 tickets complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:56:32 +01:00
jpmschweitzer 30a5311f24 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-25 16:54:29 +01:00
jpmschweitzerandClaude Opus 4.6 d676a8f41e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:29 +01:00
jpmschweitzerandClaude Opus 4.6 6bf7d342f3 chore(skills): add planning team ticket type to sprint-plan
Supports design discussion tickets that run on the planning branch
with a purpose-assembled agent panel. Includes Qatux (documenter)
and SI (project manager) for bookkeeping alongside domain agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:16 +01:00
jpmschweitzerandClaude Opus 4.6 2175349c56 docs(sprints): add sprint 20 "Shape" briefings
Server (6 tickets): template role/spatial/ownership schemas, triangle
definition, generation, and escalation. Client (4 tickets): code quality
refactors from review pass. Planning (1 ticket): #153 station district
layout design discussion with purpose-assembled agent panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:11 +01:00
jpmschweitzerandClaude Opus 4.6 3725a3df5e fix(client): address PR #70 review — event leak, quit flush, notification color
- input_mapper.gd: call set_input_as_handled() before early return on
  empty game_id so F5/F6 events don't propagate to other handlers
- session_manager.gd: defer scene change by one frame after buffering
  quit-save so SimBridge._process() flushes the outbound buffer
- monologue_display.gd: tag queued notifications with is_notification
  flag so drain path routes to _show_notification_line (correct color)
  instead of _show_line (lattice-profile fallback color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:43:53 +01:00
jpmschweitzerandClaude Opus 4.6 e1ea07e746 feat(client): save/load client UI — F5/F6 quicksave/quickload (#554)
Wire SaveGame/LoadGame player actions through the full client stack:
protocol v15 decode, InputMapper F5/F6 bindings, SimBridge wire mapping
with one-shot carry-forward, GameState save_result field, and HUD
notification via monologue display. Quit-to-menu triggers quicksave
before scene change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 704415fa99 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:11:33 +01:00
jpmschweitzerandClaude Opus 4.6 af3398ea34 fix(ci): address PR #69 review — stale comments, dead CLI flags
- gen_fixtures.rs: version comments now say PROTOCOL_VERSION instead
  of hardcoded 14, so they stay correct across bumps
- run-ipc-benchmark: remove --iterations flag that was parsed but
  never forwarded to the Rust test (compile-time constant governs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:10:23 +01:00
jpmschweitzer a0ed10468b Merge remote-tracking branch 'origin/ci' 2026-02-25 13:09:47 +01:00
jpmschweitzerandClaude Opus 4.6 51bad2e606 fix(client): bump protocol version to 15
Server bumped to v15 in Sprint 19 (save_result field) but client
constant was never updated. Fixes cross-language fixture test failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:08:45 +01:00
jpmschweitzer cf4919913e Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-25 13:07:00 +01:00
jpmschweitzer e51650ec74 Merge origin/main into client (resolve CHANGELOG conflict) 2026-02-25 13:04:01 +01:00
jpmschweitzerandClaude Opus 4.6 fcd2fd3cf7 fix(skills): pr-review must run from main worktree, no workarounds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:02:02 +01:00
jpmschweitzerandClaude Opus 4.6 b6c4ecb302 feat(ci): protocol version handshake client + IPC benchmark (#556, #342)
#556: HANDSHAKING state in sim_bridge.gd — decodes first framed message
as HandshakeMessage, validates vs Protocol.PROTOCOL_VERSION, 5s timeout,
handshake_complete/handshake_failed signals.

#342: IPC benchmark now reads and validates HandshakeMessage before
starting the timing loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:59 +01:00
jpmschweitzerandClaude Opus 4.6 e7f1e80307 fix(client): POI category key mismatch — use poi_category from wire format
protocol.gd decoded server's poi_category as "category", minimap.gd
read "category" — both now use "poi_category" matching the wire format.
Protocol falls back to "category" for older server snapshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:29 +01:00
jpmschweitzerandClaude Opus 4.6 16e1cc3a09 chore(skills): tighten sprint agent spawn rules
Add 5 new rules to the agent spawn prompt: read-before-write,
verify-after-write, no-partial-work, message-when-blocked, and
backward-compatibility. Adds verification checklist before marking
tasks done. Addresses recurring issues with agents skipping call
site updates, leaving partial implementations, and not escalating
blockers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:56:47 +01:00
jpmschweitzerandClaude Opus 4.6 ab654c8865 test(client): add sprint 18 test suites for examine display and minimap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:52:04 +01:00
jpmschweitzer 1bd528e521 Merge remote-tracking branch 'origin/main' into ci
# Conflicts:
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/perception/observer/mod.rs
2026-02-25 12:51:41 +01:00
jpmschweitzerandClaude Opus 4.6 83a244fc2c fix(client): address PR #67 round 2 — broken tests, BBCode escape, expiry loop
- Add missing _test_input_queue proxy to SimBridge (26 call sites across
  6 test files broken by TestHarness extraction)
- Parent quit dialog to SceneTree root instead of caller node to prevent
  orphaned reference if caller freed before user responds
- Remove deprecated rng.randomize() call (Godot 4 auto-seeds)
- Clear debug overlay state (_npc_paths, tick timing) on session change
  via new GameState.game_id_changed signal to prevent entity ID collisions
- Update settings_dialog quit_to_menu() call site (no-arg signature)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:47 +01:00
jpmschweitzerandClaude Opus 4.6 01b354569b chore(config): allowlist cargo and test runner commands in settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:32 +01:00
jpmschweitzerandClaude Opus 4.6 81ec337e4d feat(ci): IPC round-trip timing benchmark scaffold (#342)
Benchmark test in ipc_bench.rs: warmup + 100 rounds, p50/p95/p99 latency
reporting, 5ms threshold. Handshake step stubbed pending #555/#556.
Invoked via tests/run-ipc-benchmark.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:25 +01:00
jpmschweitzerandClaude Opus 4.6 732b5f3adc fix(simulation): resolve duplicate examine_result field and regenerate fixtures
Removed duplicate examine_result from ObserverSnapshot (old ExamineResultEvent
superseded by ExamineResultWire). Fixed serde_default test version assertion.
Regenerated golden file and all fixture .msgpack files. 1070 Rust tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:17 +01:00
jpmschweitzerandClaude Opus 4.6 7d5fdc82b6 feat(ci): IPC serialization fixture files and cross-language tests (#271)
Five new .msgpack fixtures: snapshot_minimal, snapshot_full,
player_input_move, player_input_interact, malformed. Rust generator in
gen_fixtures.rs, 7 Rust validation tests in serialization.rs, GDScript
cross-language test in test_ipc_fixtures.gd (22 assertions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:07 +01:00
jpmschweitzerandClaude Opus 4.6 4f21465dbf feat(ci): test runner scripts and Makefile integration (#270)
Six test runner scripts at tests/: run-rust, run-godot, run-ipc-fixtures,
run-ipc-protocol, run-ipc-integration, run-all. Plus run-ipc-benchmark
for Layer 3 timing. All produce structured JSON stdout, support --filter,
and exit 0/non-zero. Makefile targets updated to delegate to scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:47:58 +01:00
jpmschweitzerandClaude Opus 4.6 2493e2b0b5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:47:43 +01:00
jpmschweitzerandClaude Opus 4.6 fdad0958b3 docs(workshops): add outcomes files for 5 completed workshops
Closes content-gap-analysis, KG-information-boundaries,
v01-content-scoping, v01-gap-analysis, and wiki-review workshops
with formal outcomes recording decisions produced, tickets created,
and open questions carried forward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:45:59 +01:00
jpmschweitzer e9ca3cadf0 Merge remote-tracking branch 'origin/server' 2026-02-25 12:45:24 +01:00
jpmschweitzerandClaude Opus 4.6 23fbfdbfc5 fix(simulation): PR #68 review — version bump, tracing warns, test coverage
- Bump PROTOCOL_VERSION 14 → 15 for save_result field addition
- Add tracing::warn on SaveLoadPending command overwrite (double-tap F5)
- Add tracing::warn on KnowledgeGraph::new() fallback during save
- Fix misleading WouldBlock comment in tcp.rs
- Document SimSpacePressure.active_count pre-eviction timing
- Document entity-based eviction tie-breaking non-determinism
- Add ScopePinned eviction survival regression test
- Regenerate msgpack fixtures for protocol v15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:32:35 +01:00
jpmschweitzerandClaude Opus 4.6 c4f210a2f6 refactor(client): extract test simulation from SimBridge to TestHarness
Moves ~300 lines of test simulation logic (Bresenham LOS, collision,
procedural room generation, movement physics, dialogue triggers) from
the production sim_bridge.gd autoload into a dedicated TestHarness
class at scripts/protocol/test_harness.gd. Enforces D-020 information
boundary — no game logic in the production client.

SimBridge retains thin proxy properties and methods for backward
compatibility with 13+ test files (zero test changes needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:23:36 +01:00
jpmschweitzerandClaude Opus 4.6 f874979ff9 docs(decisions): file 6 D-records and 10 Q-records from workshop backlog
D-087 through D-092: v0.1 triangle config, 3-state pause, self-contained
forks, PC voice registers, complicity as theme, anchor line requirement.
Q-030 through Q-039: long-term strategy questions from wiki-review R4
(seed schema, generator pipeline, Sacred/Profane, 300-world estimates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:18:53 +01:00
jpmschweitzer 17a9197b65 Merge remote-tracking branch 'origin/main' into server
# Conflicts:
#	CHANGELOG.md
2026-02-25 12:14:02 +01:00
jpmschweitzerandClaude Opus 4.6 740e97b75d chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:13:23 +01:00
jpmschweitzerandClaude Opus 4.6 6ed8d11502 feat(simulation): Sprint 19 — 7 server systems
Protocol handshake (#555): HandshakeMessage as first IPC frame,
HandshakeState resource, forward-compatible input handling.

State serialization (#96): serialize_npc_to_frozen/deserialize with
full D-024 axis coverage (10 new optional fields on NpcSaveState).

Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic
assignment from KnowledgeGraph and RelationshipGraph.

Timestamp eviction (#97): LastInteractionTick, SimSpacePressure,
BinaryHeap LRU eviction respecting ScopePinned entities.

Save/load (#553): save_to_file/load_from_file via MessagePack,
SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot.

Test infrastructure (#200): Layer 3 integration test entry point,
three-layer architecture documented per D-030.

Information boundary tests (#272): 4 negative tests proving no
passive KG leakage, LOS fog holds, tier boundary holds, save
isolation per NPC.

1063 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:13:03 +01:00
jpmschweitzerandClaude Opus 4.6 5d1d0d000c fix(client): address PR #67 review — error propagation, test cleanup
SessionManager.new_game() now returns "" on dir creation failure
instead of proceeding with a broken game-id. Main menu guards
against empty return. Test suite tracks and cleans up created
save directories in after_test().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:08:48 +01:00
jpmschweitzerandClaude Opus 4.6 1d996d7ed3 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:55:43 +01:00
jpmschweitzerandClaude Opus 4.6 d5ecd9cba8 docs(workshops): add character creation & game setup workshop brief
Resolves Q-011 (character selection). Covers: creation model (fixed/
generated/custom), seed boundary table, gate activation, quest seeding,
game condition toggles. Reference games: DF, RimWorld, Sims, Disco
Elysium, Obra Dinn, Pentiment. 5 rounds, 6 participants.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:43:21 +01:00
jpmschweitzerandClaude Opus 4.6 8e77a72a98 chore(assets): re-export wireframes without watermarks
Re-rendered all 19 wireframe PNGs via Frame0 paid license,
removing trial watermarks. JSON sources unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:37:58 +01:00
458 changed files with 63678 additions and 4219 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
- **Work in dedicated round files:** All new rounds happen in `docs/discussions/round-NN-topic.md` from the start. DISCUSSION.md is retired for new content.
- **Update the discussion index ONLY when closing:** After a round is formally closed, update `docs/discussions/README.md` with the round entry (number, topic, decisions produced, file link).
- **Update briefings:** After a round produces new decisions, update the relevant agent briefing files in `docs/briefings/`.
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `db/connectors/qdrant-index <path>`.
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index <path>`.
## Team workflow (mandatory)
+1 -1
View File
@@ -1,6 +1,6 @@
# Local Services
Endpoints are also preconfigured in `db/connectors/config.json`.
Endpoints are also preconfigured in `tooling/db/config.json`.
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
- **Qdrant:** `http://tower-of-joy:6333/`
+4 -1
View File
@@ -17,11 +17,14 @@ docs/
workshops/ # Workshop briefs and outputs
db/
schema.sql # Database schema
connectors/ # Connector scripts for SQLite and Qdrant
tooling/
db/ # Connector scripts for SQLite, Qdrant, and audio
config.json # Endpoint configuration
ticket # Ticket CLI
sprint # Sprint lifecycle CLI
sqlite_connector.py # SQLite mini MCP
qdrant_connector.py # Qdrant + ollama mini MCP
audio_connector.py # Stable Audio Open connector
.claude/
agents/ # Agent personality files
skills/ # Skill definitions
+23 -13
View File
@@ -25,20 +25,22 @@
"Bash(git ls-tree *)",
"Bash(git rev-parse --show-toplevel)",
"Bash(db/connectors/ticket *)",
"Bash(db/connectors/sprint *)",
"Bash(db/connectors/sqlite-query *)",
"Bash(db/connectors/sqlite-exec *)",
"Bash(db/connectors/qdrant-search *)",
"Bash(db/connectors/qdrant-index *)",
"Bash(db/connectors/qdrant-health)",
"Bash(db/connectors/qdrant-count)",
"Bash(db/connectors/sqlite-init)",
"Bash(db/connectors/decisions-sync)",
"Bash(tooling/db/ticket *)",
"Bash(tooling/db/sprint *)",
"Bash(tooling/db/sqlite-query *)",
"Bash(tooling/db/sqlite-exec *)",
"Bash(tooling/db/qdrant-search *)",
"Bash(tooling/db/qdrant-index *)",
"Bash(tooling/db/qdrant-health)",
"Bash(tooling/db/qdrant-count)",
"Bash(tooling/db/sqlite-init)",
"Bash(tooling/db/decisions-sync)",
"Bash(tooling/db/decision *)",
"Bash(db/connectors/audio-generate *)",
"Bash(db/connectors/audio-health)",
"Bash(db/connectors/audio-post *)",
"Bash(tooling/db/audio-generate *)",
"Bash(tooling/db/audio-health)",
"Bash(tooling/db/audio-post *)",
"Bash(tooling/db/audio-batch *)",
"Bash(make *)",
"Bash(make)",
@@ -46,6 +48,14 @@
"Bash(tea *)",
"Bash(tooling/tea-comment *)",
"Bash(cargo test *)",
"Bash(cargo test)",
"Bash(cargo build *)",
"Bash(cargo build)",
"Bash(cargo check *)",
"Bash(cargo check)",
"Bash(tests/run-*)",
"Bash(chmod *)",
"Bash(ls *)",
"Bash(find *)",
+15 -15
View File
@@ -13,7 +13,7 @@ description: >
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
wrapper scripts at `tooling/db/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
@@ -25,21 +25,21 @@ workflow, and quality validation.
```bash
# Check API health
db/connectors/audio-health
tooling/db/audio-health
# Generate a single asset (WAV only)
db/connectors/audio-generate "prompt text" \
tooling/db/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/output.wav
# Generate + post-process in one command (WAV → trim → normalize → OGG)
db/connectors/audio-generate "prompt text" \
tooling/db/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/gen/intermediate.wav \
--output-ogg client/assets/audio/final.ogg
# Batch-generate from a manifest (preferred for multiple assets)
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
```
### Parameters
@@ -138,16 +138,16 @@ AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
```bash
# Full run
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
# Dry run — preview what would be generated
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
# Generate only specific assets
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
# Skip assets that already have OGG files
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
```
### 3. Update asset docs with prompts
@@ -190,8 +190,8 @@ For one-off generation or iteration on a specific asset:
2. Read `references/sonic-palette.md` for the sonic family prefix.
3. Read `references/category-templates.md` for the matching template.
4. Assemble the full prompt.
5. Run `db/connectors/audio-health` to verify the API is up.
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
5. Run `tooling/db/audio-health` to verify the API is up.
6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to
generate and post-process in one step.
7. Verify the output (file size, duration).
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
@@ -218,12 +218,12 @@ If you need to post-process separately (e.g., re-normalizing an existing file):
```bash
# Full pipeline: trim → normalize → convert
db/connectors/audio-post pipeline input.wav --output output.ogg
tooling/db/audio-post pipeline input.wav --output output.ogg
# Individual steps
db/connectors/audio-post trim input.wav
db/connectors/audio-post normalize input.wav --lufs -16
db/connectors/audio-post convert input.wav --output output.ogg
tooling/db/audio-post trim input.wav
tooling/db/audio-post normalize input.wav --lufs -16
tooling/db/audio-post convert input.wav --output output.ogg
```
## Manual Synthesis (Insert-Tech Sounds)
+1 -1
View File
@@ -169,7 +169,7 @@ Construct the ticket title and description from the report summary and any
investigation findings. Use the ticket CLI:
```bash
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
tooling/db/ticket create bug "{title}" --team {team} --description "{description}"
```
The description should include:
+3 -3
View File
@@ -20,14 +20,14 @@ and workflows.
For precise indexing of specific content:
```bash
python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
python3 tooling/db/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
```
### Create collection
Initialize the Qdrant collection (run once during setup):
```bash
python3 db/connectors/qdrant_connector.py create-collection
python3 tooling/db/qdrant_connector.py create-collection
```
## Bulk Indexing
@@ -35,7 +35,7 @@ python3 db/connectors/qdrant_connector.py create-collection
Index all project documents at once:
```bash
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
db/connectors/qdrant-index "$f"
tooling/db/qdrant-index "$f"
done
```
+1 -1
View File
@@ -123,7 +123,7 @@ Extract ticket IDs from `#NNN` patterns. For each ticket that is
currently `in_progress`, update it to `review`:
```bash
db/connectors/ticket status <id> review
tooling/db/ticket status <id> review
```
Report which tickets were moved to review. Skip tickets that are
+18 -16
View File
@@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow
### 0. Branch guard — MUST be on `main`
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
```bash
git branch --show-current
```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run from the `main` worktree. Switch to `main` first."
Do NOT proceed with the review from a team branch.
"PR reviews must be run by a Claude instance in the `main` worktree."
Do NOT proceed with the review. Do NOT work around this by reading files
from another worktree — the review agent itself must be running in main.
Stop and wait for the user to invoke `/pr-review` from main.
### 1. Determine the branch to review
@@ -72,44 +74,44 @@ If the diff is empty, report "No changes to review" and stop.
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
2. Read source files directly from the branch 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/home/jeroenschweitzer/Projects/settled-reach/<branch>/
/var/mnt/data/projects/settled-reach/<branch>/
```
For example, the `copy` branch lives at:
For example, the `copy` team directory is at:
```
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
/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
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
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`,
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
```
Also tell agents to read relevant `decisions/*.md` files from the same
worktree (they're identical to main, but using the worktree path keeps
agents grounded in the correct directory).
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:
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
**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?
+56 -13
View File
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
When writing briefings, name the assigned agents in the **Agents** line of each
file so the team knows who to spawn.
### Planning Team Tickets
Some tickets need **design discussion** before implementation can begin — tagged
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
questions. These run on the `planning` branch as structured discussions with
the user and a purpose-assembled agent panel.
**When to create a planning ticket:**
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
- Ticket blocks 2+ downstream tickets across different teams
- Open Q-NNN items that block sprint candidates
- Architectural decisions that need multi-domain input before implementation
**Planning briefing format** (differs from implementation briefings):
- **Agents line**: List agents by domain relevance, not fixed team roster.
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
records decisions, updates domain files) and SI (project manager — creates
follow-up tickets, updates sprint assignments).
- **Discussion rounds**: Structure the conversation into 2-3 rounds
(inventory → proposals → convergence)
- **Context section**: List all existing design docs, decisions, and related
tickets that participants must read before the discussion
- **Output specification**: What the discussion must produce — typically a
D-record in `decisions/`, possibly a design doc in `docs/design/`
- **Decision questions**: Specific questions the discussion must answer,
not open-ended exploration
## Workflow
### 1. Run sprint prepare
@@ -58,7 +88,7 @@ file so the team knows who to spawn.
Get carry-overs, backlog candidates, and decision gaps in one shot:
```bash
db/connectors/sprint prepare
tooling/db/sprint prepare
```
This auto-detects the next sprint number (max ID + 1), creates the sprint
@@ -73,10 +103,10 @@ record in `planning` status if needed, and outputs:
For critical epics, check their children for granular candidates:
```bash
db/connectors/ticket children <epic_id>
tooling/db/ticket children <epic_id>
```
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
### 3. Read existing code state
@@ -124,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
@@ -154,16 +183,29 @@ Update it with the theme and goal, then assign tickets:
```bash
# Update the sprint with theme and goal
db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
# Assign tickets
db/connectors/ticket sprint assign <ticket_id> <sprint_id>
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
```
The sprint stays in `planning` status until explicitly activated via
`db/connectors/sprint start`. This prevents starting an unplanned sprint.
`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
@@ -171,3 +213,4 @@ Output:
- Carry-over count
- Open questions that need early resolution
- Files written
- Commit pushed to main
@@ -26,7 +26,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|---|-------|------------|
| #ID | Title | #dependency or — |
Use `db/connectors/ticket show <id>` for full details.
Use `tooling/db/ticket show <id>` for full details.
## Key Decisions
+65 -21
View File
@@ -38,7 +38,7 @@ When `/sprint-start` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
@@ -48,7 +48,7 @@ Then follow the **first matching case**:
First, check whether the sprint's work is actually done:
```bash
db/connectors/sprint status
tooling/db/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
@@ -81,7 +81,7 @@ explicitly chooses to close.
#### A1. Close the active sprint
```bash
db/connectors/sprint stop
tooling/db/sprint stop
```
This marks the active sprint as completed and lists carry-over candidates.
@@ -142,7 +142,7 @@ A sprint is ready to activate. Verify it looks complete:
```
2. Check the ticket count:
```bash
db/connectors/sprint status --sprint N
tooling/db/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
@@ -151,7 +151,7 @@ and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
```bash
db/connectors/sprint start
tooling/db/sprint start
```
Then report:
@@ -184,7 +184,7 @@ If the merge has conflicts, report them and stop — do not force-resolve.
Run the sprint CLI to get the full context dump in one shot:
```bash
db/connectors/sprint start-work
tooling/db/sprint start-work
```
This auto-detects the active sprint and current team from the branch.
@@ -204,7 +204,7 @@ If no matching briefing exists for the team, suggest running
For tickets that need more detail than the `start-work` summary provides:
```bash
db/connectors/ticket show <id>
tooling/db/ticket show <id>
```
### 6. Read key decisions
@@ -218,7 +218,7 @@ Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
db/connectors/ticket status <id> in_progress
tooling/db/ticket status <id> in_progress
```
Then output a summary:
@@ -288,26 +288,70 @@ Task(
prompt: "You are on the {team} team for Sprint {N}.
Branch: `{team}`
RULES:
- GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead.
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
db/connectors/ticket show 528
db/connectors/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
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.
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
tooling/db/ticket show 528
tooling/db/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
Before creating a new file, Glob for similar files to learn
the existing patterns (naming, structure, imports). Follow
the conventions you find — do not invent new ones.
4. VERIFY AFTER WRITE: After implementing a change, grep for
all references to functions/properties/classes you modified
or removed. If you renamed, moved, or deleted something,
update EVERY call site. Missing a call site breaks tests
and blocks the team.
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
parts of the ticket are implemented. If the ticket says
'deliver A, B, and C', all three must exist and work. If
you cannot complete part of a task, message the team lead
explaining what is blocked and what remains — do NOT mark
it completed.
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
in 3 attempts, stop and message the team lead immediately.
Do not silently skip work or leave stubs. Do not move to
the next task while the current one is incomplete.
7. BACKWARD COMPATIBILITY: When extracting, moving, or
refactoring code, ensure all existing consumers still work.
Add proxy methods/properties if needed. Grep for the old
name to find every call site.
WORKFLOW:
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
2. Read the decision files referenced in the briefing.
3. Check TaskList for available work.
4. Claim an unblocked task (TaskUpdate with owner: your name),
mark it in_progress, and implement it.
5. When done, mark the task completed and check TaskList for
the next available task.
5. Before marking done, verify:
- All deliverables from the ticket exist (not just some)
- No broken references (grep for changed names/signatures)
- New files follow existing naming and directory conventions
- Modified files still parse (no syntax errors)
6. Mark the task completed and check TaskList for the next
available task.
7. If no tasks remain, message the team lead. Do NOT shut down
on your own.
Use `db/connectors/ticket show <id>` for full ticket specs.",
Use `tooling/db/ticket show <id>` for full ticket specs.",
description: "Sprint {N} {team}: {name}",
run_in_background: true
)
+1 -1
View File
@@ -41,7 +41,7 @@ the workflow.
Run these two commands in parallel:
```bash
db/connectors/sprint sweep
tooling/db/sprint sweep
```
```bash
+16 -16
View File
@@ -17,60 +17,60 @@ section. This skill covers the full command reference.
### List tickets (full flags)
```bash
db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
```
### Create ticket
```bash
db/connectors/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
```
Types: `initiative`, `epic`, `story`, `task`, `bug`
Priorities: `critical`, `high`, `medium`, `low`
### Update status
```bash
db/connectors/ticket status <id> <new_status>
db/connectors/ticket done <id> [<id> ...]
tooling/db/ticket status <id> <new_status>
tooling/db/ticket done <id> [<id> ...]
```
Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled`
### Assignment
```bash
db/connectors/ticket assign <id> <agent>
db/connectors/ticket unassign <id>
tooling/db/ticket assign <id> <agent>
tooling/db/ticket unassign <id>
```
### Team assignment
```bash
db/connectors/ticket team <id> <teams>
tooling/db/ticket team <id> <teams>
```
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
### Sprint management
```bash
db/connectors/ticket sprint [--active]
db/connectors/ticket sprint assign <id> <sprint_id>
tooling/db/ticket sprint [--active]
tooling/db/ticket sprint assign <id> <sprint_id>
```
For sprint-scoped operations (status overview, context dumps, lifecycle),
use the dedicated sprint CLI instead: `db/connectors/sprint --help`
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
### Dependencies
```bash
db/connectors/ticket deps <id>
tooling/db/ticket deps <id>
```
### Search and browse
```bash
db/connectors/ticket search <keyword>
db/connectors/ticket epics [--status S]
db/connectors/ticket children <id>
db/connectors/ticket count [--status S]
tooling/db/ticket search <keyword>
tooling/db/ticket epics [--status S]
tooling/db/ticket children <id>
tooling/db/ticket count [--status S]
```
### Batch show
```bash
db/connectors/ticket show --brief <id> [<id>...]
tooling/db/ticket show --brief <id> [<id>...]
```
## Workflow
+1
View File
@@ -21,6 +21,7 @@ run_check() {
# --- Checks ---
run_check "tooling/check-fact-ids" "fact_id validation"
run_check "tooling/check-decision-ids" "decision ID duplication"
if [ "$ERRORS" -gt 0 ]; then
echo ""
+1
View File
@@ -43,3 +43,4 @@ Thumbs.db
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
.claude/plans/
.claude/projects/
.claude/agent-memory/
+157
View File
@@ -7,11 +7,161 @@ 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
- Visual test harness — `make screenshot`, `make test-visual`, `make visual-update` for automated visual regression testing with golden PNGs across 11 scenarios (fog, HUD, dialogue, minimap)
- Visual movie mode — `make visual-movie` captures interaction flows as frame sequences with contact sheet generation
- World seed protocol — StartupMessage carries world_seed from client to server after handshake, enabling deterministic NPC population seeding (D-010, D-029)
- EntanglementConfig — per-seed NPC population ratios (flat/mundane/intrigue) sampled from seeded RNG with D-029 bounds, ensuring same seed = same world (#175, #178)
- Fog debug mode — toggle FogState.debug_exploration to render raw exploration texture as colored overlay for diagnostic use
- D-110 through D-112: z-level addressing, subterranean architecture, no instancing decisions
- Q-051: speech bubble indicator over speaking NPCs
- Sprint 22 "Wire" briefings (server, client, visual, CI, planning, joint)
### Added (server)
- Production NPC pool generation — 23 authored Sova NPCs spawn with EntanglementTag (Flat/Intrigue) based on triangle membership (#176, D-029)
- Authored triangle instantiation — 5 Sova triangles (3 active forks, 2 passive tensions) loaded from content YAML with deterministic IDs (#188, D-087)
- Contamination activation mechanic — timer-based storyteller fires after 30 game-minutes, pressures active triangles, emits ContaminationEvent (#254)
- Modifications data model stub — Vec<Modification> on chunk entities, round-trips through save/load for future construction DLC (#567, D-112)
- Zone Gate gauntlet room — two-zone test room with door boundary, zone crossing detection system (#512)
- Fuzzy map tests — 50-seed randomized testing of procedural maps against 4 structural invariants (#509)
### Fixed
- Fog shader: silent compilation failure in OpenGL3 compat mode — removed `return` statements from fragment() which are not supported, causing fog overlay to render as no-op (root cause of Sprint 22 fog regression)
- Fog system: blocky stair-stepped edges at vision cone boundary — doubled Gaussian blur step size for D-066 compliant 6-8 tile smooth gradient (#569)
- Fog system: zero visibility in explored areas — switched bounds calculation from visible_tiles (empty in live server mode) to visible_positions, and removed shader guard that cut off gradient bleed into unexplored tiles (#569)
- Fog shader alpha tuned to D-059 spec: light fog 0.25-0.35 (was 0.25-0.55), deep fog 0.55-0.70 (was 0.78-0.90) — world content now visible through fog instead of hidden behind it (#563)
### Changed
- Fog shader now distinguishes light fog (near cone, neutral dark) from deep fog (far from cone, zone temperature tint) with separate Perlin noise breathing cycles (8-10s / 15-20s)
- Zone temperature tint populated per-tile from server zone_id: bar=warm amber-dark, hub=cool blue-dark, corridor=neutral dark (D-059/D-046/D-077)
- Simplified vision cone from 3-sector (forward/peripheral/blind) to forward-only 120° arc — server sends only forward-cone tiles, client renders explored tiles behind the player with light fog overlay
- Simplified fog shader from 5-layer to 3-layer model (clear, explored, unexplored)
- Fog texture resize now preserves exploration data — tiles behind the player stay as light fog instead of reverting to unexplored black
- Updated D-015/D-017 perception decisions to reflect simplified cone model
- Moved connector scripts from db/connectors/ to tooling/db/ (#274) — backwards-compat symlink removed in #568
### Removed
- db/connectors symlink — all references now use tooling/db/ directly (#568)
## [v0.1.20] — 2026-02-25
### Added
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
### Fixed
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
- Validate dangling with_role references in TriangleDef constraint validation
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
- Consolidate near-identical unit tests with integration counterparts
### Changed
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
## [v0.1.19] — 2026-02-25
### Added
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
- CI PR #69 merged — Sprint 19 test runners, IPC fixtures, protocol handshake, benchmark (4 tickets, 1297 lines)
- Test runner scripts — 7 bash scripts (run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-ipc-benchmark, run-all) with structured JSON output (#270, D-030)
- IPC serialization fixtures — 5 msgpack fixtures with Rust generator, cross-language GDScript validation (22 assertions) (#271, D-030)
- Protocol handshake client — HANDSHAKING state in SimBridge, HandshakeMessage decode with 5s timeout (#556, D-020)
- IPC round-trip benchmark — p50/p95/p99 latency reporting, 5ms threshold (#342, D-020)
- Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020)
- Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation
- State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026)
- Scope tag system — `ScopeTagKind` (Neighborhood, ActiveQuest, Colleague, KnownContact), `ScopePinned` marker, automatic assignment from KnowledgeGraph and RelationshipGraph (#98, D-026)
- Timestamp-based eviction — `LastInteractionTick` LRU tracking, `SimSpacePressure` resource, BinaryHeap eviction respecting scope-pinned entities, Active cap 80 (#97, D-026)
- Save/load ECS extraction — `save_to_file`/`load_from_file` via MessagePack, `SaveGame`/`LoadGame` IPC commands, `SaveLoadResultWire` on ObserverSnapshot (#553, D-085)
- ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks
- Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200)
- Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010)
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API
- Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review
- D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme)
- Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology)
- Decision ID claim system — `db/connectors/decision` CLI with `next`, `claim`, `check-dupes` commands to prevent cross-worktree D/Q/R ID collisions, pre-commit duplicate check
- D-085: Per-game save directory structure — every new game creates `user://saves/<game-id>/`, F5 quicksave, F6 quickload
- Q-029: Save file format design — long-term considerations for versioning, compression, integrity, metadata headers
- D-086: Renumbered insert icon system (was D-084 on visual branch) to resolve cross-worktree ID collision
- Save/load wireframe updated for D-085 — LOAD tab shows games grouped by directory with expand/collapse, QUICKSAVE slot, F5/F6 hints
- Sprint 19: Persist planned — 16 tickets (server 7, client 5, CI 4) covering save/load, tier eviction/scope, test infrastructure
- Character creation & game setup workshop brief — covers creation model, seed boundary, gate activation, quest seeding, game toggles (resolves Q-011)
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
@@ -26,6 +176,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
@@ -40,7 +191,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
### Fixed
- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field)
- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant
- 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
+25 -15
View File
@@ -14,7 +14,7 @@ server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
docs/ # Architecture, design, briefings, sprints, workshops
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
db/ # Schema + seed data (connectors moved to tooling/db/)
.claude/ # Agents, skills, rules
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
```
@@ -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
@@ -42,7 +43,7 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
### Before starting work
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
2. Use `db/connectors/ticket show <id>` for full ticket details
2. Use `tooling/db/ticket show <id>` for full ticket details
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
@@ -52,17 +53,26 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
| Tool | Command | Full reference |
|------|---------|----------------|
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
| 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)
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
- Tickets: managed via `tooling/db/ticket` CLI or `/ticket` skill
+57 -15
View File
@@ -5,9 +5,11 @@ 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
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
screenshot visual-movie test-visual visual-update
# --- Configuration ---
@@ -23,11 +25,15 @@ help:
@echo " make stop Stop any running server instance"
@echo " make client Run the Godot client (test mode)"
@echo " make server Run the Rust simulation server"
@echo " make test Run all tests"
@echo " make lint Run all linters"
@echo " make ci Run full CI pipeline locally"
@echo " make ci-client Run client CI checks"
@echo " make ci-server Run server CI checks"
@echo " make test Run all tests"
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
@echo " make lint Run all linters"
@echo " make ci Run full CI pipeline locally"
@echo " make ci-client Run client CI checks"
@echo " make ci-server Run server CI checks"
@echo " make check-protocol Verify server/client protocol versions match"
@echo " make clean Remove build artifacts and caches"
@echo ""
@@ -48,6 +54,11 @@ help:
@echo " make checklist-generate Validate checklists + print condition summary"
@echo " make perf-baseline Run performance benchmarks and save baseline"
@echo ""
@echo " make screenshot Ad-hoc visual capture (SCENARIO=name, default: fog_3state)"
@echo " make visual-movie Flow capture with contact sheet (FLOW=name)"
@echo " make test-visual Run visual golden regression tests"
@echo " make visual-update Regenerate visual goldens and stage for commit"
@echo ""
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
@@ -99,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 ---
@@ -128,11 +142,14 @@ stop:
test: test-server test-client
test-server:
cd server && cargo nextest run
tests/run-rust
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..."
@@ -169,8 +186,19 @@ golden-update:
@echo "Review with: git diff --cached -- server/tests/golden/"
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
tests/run-godot
test-ipc-fixtures:
tests/run-ipc-fixtures
test-ipc-protocol:
tests/run-ipc-protocol
test-ipc-integration:
tests/run-ipc-integration
test-ipc-benchmark:
tests/run-ipc-benchmark
# --- Lint ---
@@ -268,16 +296,16 @@ db-install:
# --- Decisions ---
decisions-sync:
@db/connectors/decisions-sync
@tooling/db/decisions-sync
decisions-coverage:
@db/connectors/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
@tooling/db/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
decisions-active:
@db/connectors/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
@tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
decisions-orphan:
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
@tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
# --- Content Validation ---
@@ -302,6 +330,20 @@ debug-schedule:
@echo "Dumping bevy_ecs schedule graph..."
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
# --- Visual test harness ---
screenshot:
@tests/run-visual --screenshot $(SCENARIO)
visual-movie:
@tests/run-visual --movie $(FLOW)
test-visual:
@tests/run-visual
visual-update:
@tests/run-visual --update
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -311,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."
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://850hx6cd5kx7"
path="res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/npc_generic_east_64.png"
dest_files=["res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx6s0yglmpt2l"
path="res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/npc_generic_north_64.png"
dest_files=["res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpsuq148mksls"
path="res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/npc_generic_south_64.png"
dest_files=["res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgt84hobbdumx"
path="res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/npc_generic_west_64.png"
dest_files=["res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bcmer1bsugk8b"
path="res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/wall_structural_east_64.png"
dest_files=["res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dd5j0q6cu674m"
path="res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/wall_structural_north_64.png"
dest_files=["res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cctlhvaolom3x"
path="res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/wall_structural_south_64.png"
dest_files=["res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ughjt5s8la2p"
path="res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/wall_structural_west_64.png"
dest_files=["res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+20
View File
@@ -104,8 +104,12 @@ notifications:
# System
save_complete: "Progress saved."
load_complete: "Session restored."
save_failed: "Save failed."
load_failed: "Load failed."
connection_lost: "Signal interrupted."
connection_restored: "Signal restored."
loading: "Resuming..."
# ============================================================
# KNOWLEDGE PANEL LABELS
@@ -125,6 +129,17 @@ knowledge_panel:
confidence_medium: "Likely"
confidence_low: "Unconfirmed"
confidence_rumor: "Hearsay"
# D-041 KnowledgeConfidence levels — displayed in journal panel
confidence_direct: "Confirmed"
confidence_knowsdetails: "Detailed"
confidence_knowsof: "Known"
confidence_suspects: "Unconfirmed"
# D-041 KnowledgeSource labels — displayed in journal panel
source_directobservation: "Observed"
source_toldby: "Told"
source_heard: "Overheard"
source_inferred: "Inferred"
source_background: "Prior"
# ============================================================
# TUTORIAL TEXT (DIEGETIC)
@@ -159,6 +174,8 @@ dialogue:
menu:
pause_title: "Paused"
resume: "Resume"
new_game: "New Game"
continue: "Continue"
settings: "Settings"
save_game: "Save"
load_game: "Load"
@@ -167,6 +184,9 @@ menu:
confirm_quit: "Unsaved progress will be lost."
confirm_yes: "Yes"
confirm_no: "No"
load_game_browse: "LOAD GAME"
load_game_back: "BACK"
load_game_empty: "No saves found."
settings:
audio_volume: "Volume"
+17 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="The Settled Reach"
run/main_scene="res://scenes/main.tscn"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
SessionManager="*res://scripts/autoloads/session_manager.gd"
[audio]
@@ -125,11 +126,26 @@ debug_overlay={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
open_journal={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
]
}
teleport_hub={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
quicksave={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
quickload={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+21 -4
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=24 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"]
@@ -21,8 +21,12 @@
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="Script" path="res://ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
[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")
@@ -134,6 +138,15 @@ layer = 10
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
@@ -141,8 +154,6 @@ layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
; D-053: Stance indicator — top-right, color-coded
@@ -181,3 +192,9 @@ layer = 30
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
; #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")]
+124
View File
@@ -0,0 +1,124 @@
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
; Main menu — New Game / Continue / Quit.
; #258: D-085 per-game save directory created on New Game.
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_mainmenu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.05, 0.08, 1.0)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -120.0
offset_top = -80.0
offset_right = 120.0
offset_bottom = 100.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 16
alignment = 1
[node name="TitleLabel" type="Label" parent="VBox"]
layout_mode = 2
text = "THE SETTLED REACH"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 36
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
[node name="Spacer" type="Control" parent="VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 24)
[node name="NewGameBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "NEW GAME"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="ContinueBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "CONTINUE"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="LoadGameBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "LOAD GAME"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="QuitBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "QUIT"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
[node name="LoadGamePanel" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
visible = false
[node name="PanelBg" type="ColorRect" parent="LoadGamePanel"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.05, 0.08, 0.96)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="LoadGamePanel"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -160.0
offset_top = -180.0
offset_right = 160.0
offset_bottom = 180.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="LoadGamePanel/VBox"]
layout_mode = 2
text = "LOAD GAME"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 20
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
[node name="SavesScroll" type="ScrollContainer" parent="LoadGamePanel/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(320, 240)
[node name="SavesList" type="VBoxContainer" parent="LoadGamePanel/VBox/SavesScroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="BackBtn" type="Button" parent="LoadGamePanel/VBox"]
layout_mode = 2
text = "BACK"
theme_override_font_sizes/font_size = 14
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
+144 -25
View File
@@ -8,13 +8,29 @@ extends Node
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
# Low saturation is intentional (D-046): tints are subtle — distinguishable as warm/cool/neutral
# in side-by-side comparison, not garish. The "Hopper test" validates this.
# Colors are dark tints used as the fog overlay in the deep fog zone:
# hub/workplace: #1a1f2e (cool blue-dark — terminal, institutional)
# bar: #2a1f15 (warm amber-dark — social, inhabited)
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
const ZONE_TINTS: Dictionary = {
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
"workplace": Color(0.102, 0.122, 0.180), # same as hub
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
}
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
@@ -25,16 +41,34 @@ var _exp_bytes: PackedByteArray
var _vis_image: Image
var _exp_image: Image
var _tint_image: Image
# Zone tint stored as 3-channel RGB bytes (R, G, B per pixel) for preservation across resizes
var _tint_bytes: PackedByteArray
var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
## Debug flag — when true, fog.gdshader renders raw exploration texture
## as colored overlay (green=visible, blue=explored, red=unexplored).
## Toggle via FogState.debug_exploration = true in the console.
var debug_exploration: bool = false
## Deterministic shader time for visual test captures.
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
## Set before settle frames so noise phase is reproducible across runs.
var override_time: float = -1.0
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
func _resize(bounds: Rect2i) -> void:
var old_bounds := map_bounds
var old_exp := _exp_bytes
var old_tint := _tint_bytes # empty on first call (_ready); guard at line 104 skips copy
var old_w := _width
var old_h := _height
map_bounds = bounds
_width = maxi(bounds.size.x, 1)
_height = maxi(bounds.size.y, 1)
@@ -49,41 +83,92 @@ func _resize(bounds: Rect2i) -> void:
_exp_bytes = PackedByteArray()
_exp_bytes.resize(sz)
_exp_bytes.fill(EXP_UNEXPLORED)
# Preserve exploration data from old bounds into new bounds
if old_exp.size() > 0 and old_w > 0 and old_h > 0:
var dx: int = old_bounds.position.x - bounds.position.x
var dy: int = old_bounds.position.y - bounds.position.y
for oy in range(old_h):
var ny: int = oy + dy
if ny < 0 or ny >= _height:
continue
for ox in range(old_w):
var nx: int = ox + dx
if nx < 0 or nx >= _width:
continue
var old_val: int = old_exp[oy * old_w + ox]
if old_val > EXP_UNEXPLORED:
_exp_bytes[ny * _width + nx] = old_val
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
_tint_image.fill(Color(0.05, 0.05, 0.08))
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
var tint_sz := sz * 3
_tint_bytes = PackedByteArray()
_tint_bytes.resize(tint_sz)
var default_r := int(ZONE_TINT_DEFAULT.r * 255.0)
var default_g := int(ZONE_TINT_DEFAULT.g * 255.0)
var default_b := int(ZONE_TINT_DEFAULT.b * 255.0)
for i in range(sz):
_tint_bytes[i * 3 + 0] = default_r
_tint_bytes[i * 3 + 1] = default_g
_tint_bytes[i * 3 + 2] = default_b
# Preserve zone tint data from old bounds (zone tints are stable — tile zone never changes)
if old_tint.size() > 0 and old_w > 0 and old_h > 0:
var dx: int = old_bounds.position.x - bounds.position.x
var dy: int = old_bounds.position.y - bounds.position.y
for oy in range(old_h):
var ny: int = oy + dy
if ny < 0 or ny >= _height:
continue
for ox in range(old_w):
var nx: int = ox + dx
if nx < 0 or nx >= _width:
continue
var old_idx := (oy * old_w + ox) * 3
var new_idx := (ny * _width + nx) * 3
_tint_bytes[new_idx + 0] = old_tint[old_idx + 0]
_tint_bytes[new_idx + 1] = old_tint[old_idx + 1]
_tint_bytes[new_idx + 2] = old_tint[old_idx + 2]
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
func update_from_state() -> void:
# Resize if map bounds changed
var tiles := GameState.visible_tiles
if tiles.size() > 0:
var new_bounds := _compute_bounds(tiles)
# Grow bounds to include newly visible tiles — never shrink, so explored
# tiles behind the player stay in the texture and render as deep fog
# instead of black. Exploration data is preserved across resizes.
# Use visible_positions (always populated from server snapshots) instead of
# visible_tiles, which stays empty in live server mode because the server
# sends tile_kind but game_state.gd's population check expects "type".
var positions: Dictionary = GameState.visible_positions
if positions.size() > 0:
var new_bounds := _grow_bounds_from_positions(positions)
if new_bounds != map_bounds:
_resize(new_bounds)
var ox: int = map_bounds.position.x
var oy: int = map_bounds.position.y
var positions: Dictionary = GameState.visible_positions
var sectors: Dictionary = GameState.visibility_sectors
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
# 1. Clear visibility, then write current LOS
# 1. Clear visibility, then write current LOS (all tiles are Forward)
_vis_bytes.fill(VIS_HIDDEN)
for pos in 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
var sector: String = sectors.get(pos, "Forward")
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
_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)
@@ -106,24 +191,58 @@ func update_from_state() -> void:
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
# 3. Zone tint: write zone temperature color for currently visible tiles.
# Zone data is stable (tile zone never changes) so we only write on first sight.
# Data persists in _tint_bytes across frames and across resizes.
# visible_tiles carries zone_id per tile (populated from snapshot "tiles" or
# "visible_tiles" with type field — see game_state.gd apply_snapshot).
var tiles := GameState.visible_tiles
var tint_dirty := false
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
var zone_id: String = str(tile.get("zone_id", ""))
if zone_id.is_empty():
continue
var tint_color: Color = ZONE_TINTS.get(zone_id, ZONE_TINT_DEFAULT)
var px: int = int(tile.x) - ox
var py: int = int(tile.y) - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var tint_idx := (py * _width + px) * 3
var new_r := int(tint_color.r * 255.0)
var new_g := int(tint_color.g * 255.0)
var new_b := int(tint_color.b * 255.0)
# Only update if different from current (avoid spurious texture uploads)
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
_tint_bytes[tint_idx + 0] = new_r
_tint_bytes[tint_idx + 1] = new_g
_tint_bytes[tint_idx + 2] = new_b
tint_dirty = true
if tint_dirty:
_tint_image.set_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture.update(_tint_image)
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
_prev_visible = positions.duplicate()
func _compute_bounds(tiles: Array) -> Rect2i:
func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
## Compute bounds from visible_positions (Dictionary[Vector2i, bool]).
var min_x := 999999
var min_y := 999999
var max_x := -999999
var max_y := -999999
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
min_x = mini(min_x, int(tile.x))
min_y = mini(min_y, int(tile.y))
max_x = maxi(max_x, int(tile.x))
max_y = maxi(max_y, int(tile.y))
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
for pos in positions:
min_x = mini(min_x, pos.x)
min_y = mini(min_y, pos.y)
max_x = maxi(max_x, pos.x)
max_y = maxi(max_y, pos.y)
if min_x > max_x:
return Rect2i(0, 0, 1, 1)
# Margin for fog gradient bleed at edges
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
return map_bounds
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
return tile_bounds
return map_bounds.merge(tile_bounds)
+125 -22
View File
@@ -1,14 +1,25 @@
extends Node
signal game_id_changed(new_id: String)
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
# Tiles use format: [{x, y, z, type}].
var current_snapshot: Dictionary = {}
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
# Set by SessionManager.new_game() or SessionManager.resume_game().
# Empty string when no session is active (main menu state).
var current_game_id: String = "":
set(v):
current_game_id = v
game_id_changed.emit(v)
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
@@ -56,10 +67,31 @@ var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode fla
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
var insert_active: bool = true
# #175: World seed for deterministic simulation (D-010, D-029).
# Set by SessionManager.new_game(), sent to server via StartupMessage in SimBridge.
# Same seed → same EntanglementConfig → same NPC population across playthroughs.
# Persists for the session lifetime; not overwritten by apply_snapshot().
var world_seed: int = 0
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
# Null in v0.1 (server does not yet send this field; protocol change required).
var rng_seed: Variant = null
# v15 fields (#554, D-085): save/load result from server.
# {success: bool, kind: "save"|"load", error: Variant} or 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.
var pending_load_path: String = ""
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -70,6 +102,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
var conversation_ended: Array = [] # [{speaker_id, target_id}]
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
# Populated from snapshot "poi_list" field — only updated when field present.
var discovered_pois: Array = []
# v14 fields (#174, #242): Character-filtered examine result.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var current_examine_result: Variant = null
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
var medium_sound_events: Array = []
@@ -79,14 +126,17 @@ var medium_sound_events: Array = []
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
# Fallback: client-side accumulation (deprecated, remove when server populates field).
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
# D-020: Read directly from snapshot "zone_id" field.
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
# Empty string when zone_id field absent.
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
@@ -110,12 +160,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
# Prefer server-sent value; fall back to client-side accumulation until server populates.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
# D-020: direct field assignment from server-authoritative snapshot.
stationary_ticks = snapshot.stationary_ticks
else:
stationary_ticks = 0
# DEPRECATED fallback — client-side accumulation. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
# driving state on the client). Server tracks this in ListeningFocus component.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
@@ -240,29 +297,75 @@ func apply_snapshot(snapshot: Dictionary) -> void:
medium_sound_events = []
close_sound_events = []
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
# Only update if the field is present — absence means "no change since last tick".
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
discovered_pois = snapshot.discovered_pois
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
discovered_pois = snapshot.poi_list
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
current_examine_result = snapshot.examine_result
else:
current_examine_result = null
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
save_result = snapshot.save_result
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:
player_knowledge = snapshot.player_knowledge
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
# Prefer server-sent top-level value; fall back to client-side tile lookup until
# server populates top-level "zone_id" in ObserverSnapshot.
if snapshot.has("zone_id") and snapshot.zone_id is String:
# D-020: direct field assignment from server-authoritative snapshot.
current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
# identity on the client via tile iteration). Server sends zone_id per
# VisibleTile but not as a top-level snapshot field.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
# 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
+20 -2
View File
@@ -19,8 +19,12 @@ enum Action {
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
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] = []
@@ -106,15 +110,29 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("open_journal"):
action = Action.OPEN_JOURNAL
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
elif event.is_action_pressed("quicksave"):
action = Action.SAVE_GAME
elif event.is_action_pressed("quickload"):
action = Action.LOAD_GAME
if action != -1:
input_queue.append({
var entry := {
"action": action,
"timestamp_msec": Time.get_ticks_msec(),
})
}
# #554: Attach save path for SaveGame/LoadGame actions
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
var game_id := GameState.current_game_id
if game_id.is_empty():
get_viewport().set_input_as_handled()
return # No active session — ignore save/load
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
input_queue.append(entry)
get_viewport().set_input_as_handled()
+165
View File
@@ -0,0 +1,165 @@
extends Node
## D-085 (#258): Game session lifecycle manager.
## Creates per-game save directories on New Game, resumes existing sessions,
## and handles quit-to-menu flow with save confirmation.
##
## All save dirs live under user://saves/<game-id>/ where game-id is
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
const SAVES_DIR := "user://saves/"
const GAME_SCENE := "res://scenes/main.tscn"
const MENU_SCENE := "res://scenes/main_menu.tscn"
var _quit_dialog: ConfirmationDialog = null
## Generate a new game-id, create its save directory, and activate the session.
## Returns the new game-id string.
func new_game() -> String:
var now := Time.get_datetime_dict_from_system()
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
now.year, now.month, now.day,
now.hour, now.minute, now.second,
]
var rng := RandomNumberGenerator.new()
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
var game_id := "%s-%s" % [timestamp, hex_seed]
var save_path := SAVES_DIR + game_id + "/"
var err := DirAccess.make_dir_recursive_absolute(save_path)
if err != OK:
push_error("SessionManager: failed to create save dir %s: %s" % [
save_path, error_string(err)])
return ""
GameState.current_game_id = game_id
# #175: Generate world_seed for deterministic simulation (D-010, D-029).
# Combines two randi() calls (u32 each) into 63-bit entropy range.
# Mask bit 31 of the upper word before shifting to prevent signed overflow:
# GDScript int is i64 — if bit 63 is set, MessagePack encodes as negative,
# and Rust rmp_serde rejects negative values when deserializing as u64.
GameState.world_seed = ((rng.randi() & 0x7FFFFFFF) << 32) | rng.randi()
# Persist world_seed to save directory so resume_game() can restore it.
# Without this, loaded sessions would send seed=0, breaking D-010 determinism.
_write_seed_file(save_path, GameState.world_seed)
return game_id
## Resume an existing game session by setting the active game-id.
## Restores world_seed from the save directory for D-010 deterministic replay.
func resume_game(game_id: String) -> void:
GameState.current_game_id = game_id
var save_path := SAVES_DIR + game_id + "/"
GameState.world_seed = _read_seed_file(save_path)
## List all game directories under user://saves/ sorted by last-modified (most recent first).
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
func list_game_dirs() -> Array:
var dir := DirAccess.open(SAVES_DIR)
if dir == null:
return []
var results: Array = []
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if dir.current_is_dir() and not entry.begins_with("."):
var dir_path := SAVES_DIR + entry + "/"
var newest_save := _find_newest_save(dir_path)
var mtime: int = 0
if newest_save != "":
mtime = FileAccess.get_modified_time(dir_path + newest_save)
results.append({
"game_id": entry,
"modified_time": mtime,
"newest_save": newest_save,
})
entry = dir.get_next()
dir.list_dir_end()
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.modified_time > b.modified_time)
return results
## Show "Save before quitting?" confirmation dialog, then return to main menu.
## #554: The actual F5 save will be wired here once server supports SaveCommand.
func quit_to_menu() -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
return # Dialog already open
_quit_dialog = ConfirmationDialog.new()
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
get_tree().root.add_child(_quit_dialog)
_quit_dialog.confirmed.connect(_do_quit_to_menu)
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
_quit_dialog.popup_centered()
func _do_quit_to_menu() -> void:
_cleanup_quit_dialog()
# #554: Trigger quicksave before navigating to menu.
# send_input() buffers the command — defer scene change by one frame so
# SimBridge._process() flushes the outbound buffer before teardown.
if not GameState.current_game_id.is_empty():
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
SimBridge.send_input({
"action": InputMapper.Action.SAVE_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": path},
})
GameState.current_game_id = ""
_navigate_to_menu.call_deferred()
else:
GameState.current_game_id = ""
get_tree().change_scene_to_file(MENU_SCENE)
func _navigate_to_menu() -> void:
get_tree().change_scene_to_file(MENU_SCENE)
func _cleanup_quit_dialog() -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
_quit_dialog.queue_free()
_quit_dialog = null
## Write world_seed to a file in the save directory for session persistence.
func _write_seed_file(save_path: String, seed: int) -> void:
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
if file == null:
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
return
file.store_64(seed)
## Read world_seed from save directory. Returns 0 if file missing (legacy saves).
## Masks the sign bit on read: save files written before the signed-overflow fix
## may contain negative i64 values that Rust rmp_serde rejects as u64.
func _read_seed_file(save_path: String) -> int:
var file := FileAccess.open(save_path + "world_seed", FileAccess.READ)
if file == null:
push_warning("SessionManager: no seed file in %s — using seed=0 (legacy save)" % save_path)
return 0
return file.get_64() & 0x7FFFFFFFFFFFFFFF
func _find_newest_save(dir_path: String) -> String:
var dir := DirAccess.open(dir_path)
if dir == null:
return ""
var best_name := ""
var best_time: int = 0
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if not dir.current_is_dir() and entry.ends_with(".sav"):
var mtime := FileAccess.get_modified_time(dir_path + entry)
if mtime > best_time:
best_time = mtime
best_name = entry
entry = dir.get_next()
dir.list_dir_end()
return best_name
@@ -0,0 +1 @@
uid://b357ok64jc8vp
+161 -342
View File
@@ -1,17 +1,11 @@
extends Node
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
var _test_tick: int = 0
var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
var _connect_retries: int = 0
var _retry_timer: float = 0.0
# Handshake state (#556)
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
var _handshake_start_usec: int = 0
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal handshake_complete(protocol_version: int)
signal handshake_failed(reason: String)
func _ready() -> void:
if test_mode:
harness = TestHarness.new()
print("SimBridge: Running in test mode (dynamic snapshot)")
# Reset test state — call before tests that use _test_snapshot()
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
func reset_test_state() -> void:
_test_tick = 0
_test_player_pos = Vector2i(10, 10)
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
if harness: harness.reset()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to)
var _test_tick: int:
get: return harness.tick if harness else 0
set(v):
if harness: harness.tick = v
var _test_player_pos: Vector2i:
get: return harness.player_pos if harness else Vector2i.ZERO
set(v):
if harness: harness.player_pos = v
var _test_facing: String:
get: return harness.facing if harness else "North"
set(v):
if harness: harness.facing = v
var _test_in_dialogue: bool:
get: return harness.in_dialogue if harness else false
set(v):
if harness: harness.in_dialogue = v
var _test_gauntlet_mode: bool:
get: return harness.gauntlet_mode if harness else false
set(v):
if harness: harness.gauntlet_mode = v
var _test_npc_relationship: String:
get: return harness.npc_relationship if harness else "Unknown"
set(v):
if harness: harness.npc_relationship = v
var _test_input_queue: Array:
get: return harness.input_queue if harness else []
# -- Connection lifecycle ------------------------------------------------------
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid := _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
_set_state(ConnectionState.CONNECTED)
_handshake_start_usec = Time.get_ticks_usec()
_set_state(ConnectionState.HANDSHAKING)
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
StreamPeerTCP.STATUS_ERROR:
@@ -134,6 +179,82 @@ func _process(delta: float) -> void:
_bridge = null # Reset and retry
return
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
if state == ConnectionState.HANDSHAKING:
if _bridge == null:
_set_state(ConnectionState.ERROR)
return
_bridge.poll()
# Check connection dropped during handshake
var bridge_status := _bridge.get_status()
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
var reason := "Connection dropped during handshake"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge = null
_set_state(ConnectionState.ERROR)
return
# Check timeout
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
var reason := "Handshake timeout: no message received within 5 seconds"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
# Try to read first message
var msg := _bridge.poll_message()
if msg.is_empty():
return # Not ready yet, continue polling
# Decode HandshakeMessage: { "protocol_version": N }
var decoded: Variant = Messagepack.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary) \
or not decoded.value.has("protocol_version"):
var reason := "Handshake decode failed: malformed HandshakeMessage"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
var server_version: int = decoded.value["protocol_version"]
if server_version != Protocol.PROTOCOL_VERSION:
var reason := "Protocol version mismatch: server=%d, client=%d" % [
server_version, Protocol.PROTOCOL_VERSION]
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
# Send startup message with world_seed (#175, D-010/D-029).
# Server blocks waiting for this before entering the tick loop.
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed)
if startup_bytes.size() > 0:
var send_err := _bridge.send_message(startup_bytes)
if send_err != OK:
var reason := "Failed to send startup message: %s" % error_string(send_err)
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
else:
var reason := "Failed to encode startup message"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
handshake_complete.emit(server_version)
_set_state(ConnectionState.CONNECTED)
return
if _bridge == null:
return
@@ -170,9 +291,13 @@ func _process(delta: float) -> void:
push_warning("SimBridge: connection lost")
_set_state(ConnectionState.DISCONNECTED)
# -- Input / snapshot ----------------------------------------------------------
# Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
# In test mode, inputs are delegated to the test harness.
# In live mode, encoded and buffered for transport.
# Returns OK on success, or an error code on failure.
func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
@@ -182,25 +307,20 @@ func send_input(player_input: Dictionary) -> Error:
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
_test_facing = facing
harness.process_facing(facing)
else:
_test_input_queue.append(wire_name)
harness.process_input(wire_name)
return OK
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
# action_enum_to_wire already emits push_warning for invalid actions
return ERR_INVALID_PARAMETER
# Use the server's current tick so drain_for_tick processes this input immediately.
# The client-side timestamp_msec is only useful for ordering within a frame.
var tick: int = GameState.current_tick
var entry: Dictionary = { "tick": tick, "action_name": action_name }
# Data variants (e.g. UsePerceptionMode) carry payload
var action_data: Variant = player_input.get("action_data")
if action_data != null:
entry["action_data"] = action_data
@@ -208,13 +328,13 @@ func send_input(player_input: Dictionary) -> Error:
return OK
# Poll for snapshot from simulation.
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
func poll_snapshot() -> Variant:
if state != ConnectionState.CONNECTED:
return null
if test_mode:
var snapshot = _test_snapshot()
var snapshot = harness.snapshot()
snapshot_received.emit(snapshot)
return snapshot
@@ -252,6 +372,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
snapshot["save_result"] = _last_snapshot["save_result"]
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
@@ -260,6 +383,9 @@ func drain_outbound() -> Array[Dictionary]:
_outbound_buffer.clear()
return inputs
# -- Wire protocol mapping -----------------------------------------------------
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func action_enum_to_wire(action: int) -> String:
@@ -286,319 +412,12 @@ static func action_enum_to_wire(action: int) -> String:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
InputMapper.Action.SAVE_GAME:
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 ""
# Dynamic test snapshot — processes queued inputs to move player, generates
# visibility based on current position. Matches Protocol.decode_snapshot() format.
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
# map layout and can exercise the rendering pipeline independently.
func _test_snapshot() -> Dictionary:
_test_tick += 1
# Process queued inputs
for action_name in _test_input_queue:
if action_name == "TeleportToHub":
# #501: Reset to hub spawn position, clear dialogue
_test_player_pos = Vector2i(10, 10)
_test_in_dialogue = false
continue
if action_name == "Interact":
# Mock dialogue trigger (#434): if near NPC, start dialogue
var npc_pos := Vector2i(12, 9)
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
_test_in_dialogue = true
continue
var delta := _action_to_delta(action_name)
var new_pos := _test_player_pos + delta
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
_test_input_queue.clear()
var px := _test_player_pos.x
var py := _test_player_pos.y
# Build entities — player always visible
var entities: Array = [{
"entity_id": 1,
"x": float(px),
"y": float(py),
"z": 0,
"kind": { "variant": "Player", "data": null },
"visibility": "Forward",
}]
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
var npc_pos := Vector2i(12, 9)
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
entities.append({
"entity_id": 2,
"x": float(npc_pos.x),
"y": float(npc_pos.y),
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": _test_npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
var nearby: Array = []
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
nearby.append({
"entity_id": 2,
"entity_type": "Npc",
"distance": npc_dist,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
],
})
# v5: monologue on first tick (#414)
var monologue: Variant = null
if _test_tick == 1:
monologue = {
"id": "test_enter_001",
"text": "Sova Transit District. Population twelve thousand and change.",
"duration_seconds": 5.0,
}
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
# Movement (walk-away) clears it. Client consume-once guards against re-show.
# Options: structured {text, response_id, priority} per #435.
var dialogue: Variant = null
if _test_in_dialogue:
dialogue = {
"npc_name": "Kael",
"npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
"options": [
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
],
}
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
var pending_recs: Array = []
var cycle_pos := _test_tick % 12
if cycle_pos < 6:
var total_delay := 6
var remaining := total_delay - cycle_pos
pending_recs.append({
"entity_id": 100,
"x": 13.5,
"y": 12.5,
"z": 0,
"remaining_ticks": remaining,
"total_delay_ticks": total_delay,
})
# #535: Mock overheard NPC-NPC conversation (D-078)
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
# Conversation ends after 6 exchanges (~30 ticks).
var conv_events: Array = []
var conv_ended: Array = []
var conv_start := 3
var conv_lines := [
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
]
var conv_tick_interval := 5
var conv_total_ticks := conv_lines.size() * conv_tick_interval
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
var conv_index := (_test_tick - conv_start) / conv_tick_interval
var within_tick := (_test_tick - conv_start) % conv_tick_interval
if within_tick == 0 and conv_index < conv_lines.size():
var cl: Dictionary = conv_lines[conv_index]
conv_events.append({
"speaker_id": 10,
"target_id": 11,
"speaker_name": cl.speaker,
"target_name": cl.target,
"occluded_line": cl.line,
})
elif _test_tick == conv_start + conv_total_ticks:
conv_ended.append({"speaker_id": 10, "target_id": 11})
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {
"day": 0,
"time_of_day": _test_tick * 10,
"day_phase": "Morning",
"tick_rate": "Full",
},
"player_facing": _test_facing,
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"tiles": _test_tiles(),
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
"conversation_events": conv_events,
"conversation_ended": conv_ended,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
func _test_tiles() -> Array:
var tiles: Array = []
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(room_x, room_x + room_w):
for y in range(room_y, room_y + room_h):
var is_edge := (x == room_x or x == room_x + room_w - 1
or y == room_y or y == room_y + room_h - 1)
var tile_type: String
if is_edge:
# Door on the south wall, center
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
tile_type = "door"
else:
tile_type = "wall"
else:
tile_type = "floor"
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
# Corridor south of the door
var door_x := room_x + room_w / 2
for y in range(room_y + room_h, room_y + room_h + 4):
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
return tiles
# Test visible tiles with visibility sectors (v2 format)
# Tiles ahead of the player are Forward, others Peripheral.
func _test_visible_tiles() -> Array:
var vtiles: Array = []
var px := _test_player_pos.x
var py := _test_player_pos.y
var radius := 4
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:
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
return vtiles
# Test visibility: tiles within radius 4 of player, inside room bounds
func _test_visible_positions() -> Array:
var positions: Array = []
var px := _test_player_pos.x
var py := _test_player_pos.y
var radius := 4
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
# -- Test mode helpers --
const _TEST_WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
Vector2i(7,12), Vector2i(7,13),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
Vector2i(14,12), Vector2i(14,13),
# Interior wall blocking NPC
Vector2i(12, 10),
]
func _test_is_walkable(pos: Vector2i) -> bool:
return not _TEST_WALLS.has(pos)
# Simple LOS check — blocked if a wall tile sits between start and end
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
# Bresenham-lite: check tiles along the line
var dx := absi(to.x - from.x)
var dy := absi(to.y - from.y)
var sx := 1 if from.x < to.x else -1
var sy := 1 if from.y < to.y else -1
var err := dx - dy
var cx := from.x
var cy := from.y
while true:
if cx == to.x and cy == to.y:
return true
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
return false
var e2 := 2 * err
if e2 > -dy:
err -= dy
cx += sx
if e2 < dx:
err += dx
cy += sy
return true
static func _action_to_delta(action_name: String) -> Vector2i:
match action_name:
"MoveNorth": return Vector2i(0, -1)
"MoveNortheast": return Vector2i(1, -1)
"MoveEast": return Vector2i(1, 0)
"MoveSoutheast": return Vector2i(1, 1)
"MoveSouth": return Vector2i(0, 1)
"MoveSouthwest": return Vector2i(-1, 1)
"MoveWest": return Vector2i(-1, 0)
"MoveNorthwest": return Vector2i(-1, -1)
_: return Vector2i.ZERO
static func _delta_to_facing(delta: Vector2i) -> String:
match delta:
Vector2i(0, -1): return "North"
Vector2i(1, -1): return "Northeast"
Vector2i(1, 0): return "East"
Vector2i(1, 1): return "Southeast"
Vector2i(0, 1): return "South"
Vector2i(-1, 1): return "Southwest"
Vector2i(-1, 0): return "West"
Vector2i(-1, -1): return "Northwest"
_: return "North"
+2 -40
View File
@@ -49,44 +49,6 @@ func reload() -> void:
## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var stack: Array = [] # [[indent, key], ...]
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
# Trailing comment without a value — treat as section header
if val.begins_with("#"):
val = ""
# Pop sections at same or deeper indent
while stack.size() > 0 and stack.back()[0] >= indent:
stack.pop_back()
if val.is_empty():
# Section header — push onto stack
stack.push_back([indent, key])
else:
# Leaf value — extract from quotes or strip inline comment
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
val = val.substr(1, end_quote - 1)
else:
val = val.substr(1)
else:
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
var dotted_key := ""
for entry in stack:
dotted_key += entry[1] + "."
dotted_key += key
strings[dotted_key] = val
return strings
return YamlParser.parse_flat(text)
+3 -105
View File
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
# -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
return parse_checklist_yaml(text)
## Delegates to YamlParser.parse() (#560).
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
return YamlParser.parse(text)
+218 -76
View File
@@ -15,9 +15,14 @@ extends Node2D
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@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
@@ -30,6 +35,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
@@ -45,6 +51,17 @@ func _ready() -> void:
# Connect to simulation (test mode sets CONNECTED immediately)
SimBridge.connect_to_sim()
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
# In live mode, state is CONNECTING — signal handler dispatches once connected.
if not GameState.pending_load_path.is_empty():
if loading_screen:
loading_screen.show_loading()
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
_dispatch_pending_load()
else:
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
# Camera anchor: snap to player position before the first frame renders.
# In test mode poll_snapshot() returns synchronously — position is set
# immediately. In live mode the snapshot isn't available yet — _process
@@ -62,11 +79,61 @@ func _ready() -> void:
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_propagate_insert_state)
_router.register_always(_update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_play_recognition_chimes)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state)
if time_display:
_router.register_always(time_display.update_from_state)
if journal_panel:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_play_close_sound_events)
_router.register_always(_update_zone)
_router.register_always(_update_listening_focus)
_router.register_always(_consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consume_monologue)
_router.register("current_dialogue", _consume_dialogue)
_router.register("conversation_events", _consume_conversation_events)
_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:
# Main game loop: poll snapshot, apply state, flush input
@@ -86,80 +153,9 @@ func _process(delta: float) -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
var insert_state := GameState.insert_active
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
cursor_renderer.set_insert_active(insert_state)
if interaction_list and interaction_list.has_method("set_insert_active"):
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
inventory_grid.update_from_state()
# D-053: Update stance indicator
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# D-059/D-060: Update fog entity visualization (#431)
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# #559: Dispatch snapshot to registered handlers (router pattern).
# Always-run handlers update child nodes; keyed handlers fire for present fields.
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
@@ -184,6 +180,19 @@ func _process(delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #264: J — client-only, toggle knowledge journal panel
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
if input.action == InputMapper.Action.LOAD_GAME:
var err := SimBridge.send_input(input)
_pending_record_inputs.append(input)
if err == OK and loading_screen:
loading_screen.show_loading()
elif err != OK:
push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err))
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
@@ -228,6 +237,32 @@ func _process(delta: float) -> void:
_pending_record_inputs.clear()
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
func _propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func _update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
@@ -318,13 +353,17 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens (cannot be open simultaneously)
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
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
@@ -354,14 +393,48 @@ 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
# #554/#257: Show save/load result notification; hide loading screen on load complete.
func _consume_save_result() -> void:
if GameState.save_result == null:
return
var result: Dictionary = GameState.save_result
GameState.save_result = null # consume once
# #257: Dismiss loading screen regardless of success/failure
if loading_screen:
loading_screen.hide_loading()
var msg: String
if result.get("success", false):
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_complete")
else:
msg = UIStrings.get_text("notifications.load_complete")
else:
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_failed")
else:
msg = UIStrings.get_text("notifications.load_failed")
if monologue_display:
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({
@@ -413,12 +486,54 @@ func _on_dialogue_dismissed() -> void:
})
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
func _on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
func _on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
func _on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize()
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
# pending_load_path is set by main_menu.gd before scene change.
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state != SimBridge.ConnectionState.CONNECTED:
return
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load):
SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load)
_dispatch_pending_load()
func _dispatch_pending_load() -> void:
var load_path := GameState.pending_load_path
if load_path.is_empty():
return
GameState.pending_load_path = ""
var err := SimBridge.send_input({
"action": InputMapper.Action.LOAD_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": load_path},
})
if err != OK:
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
if loading_screen:
loading_screen.hide_loading(false)
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
@@ -456,6 +571,33 @@ func _teleport_transition() -> void:
tween.tween_callback(_flash_rect.queue_free)
# #174: Consume examine result — show overlay when server sends character-filtered observation.
# Clears after display (single-consume). Dismiss examine when dialogue opens.
func _consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
# Dismiss existing examine result if dialogue is active (focus priority)
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
# #264: Toggle journal panel. Called from input handler when J key pressed.
func _toggle_journal() -> void:
if not journal_panel:
return
# Journal and dialogue cannot be open simultaneously (sprint briefing)
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
+124 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 13
const PROTOCOL_VERSION: int = 18
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -206,6 +206,110 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"target_id": int(raw_end.get("target_id", 0)),
})
# v14: poi_list (#151) — discovered POIs for minimap rendering.
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
var poi_list: Array = []
var raw_pois: Variant = raw.get("poi_list")
if raw_pois is Array:
for raw_poi in raw_pois:
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
poi_list.append({
"poi_id": str(raw_poi["poi_id"]),
"name": str(raw_poi.get("name", "")),
"x": int(raw_poi["x"]),
"y": int(raw_poi["y"]),
"z": int(raw_poi.get("z", 0)),
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
})
# v14: examine_result (#174, #242) — character-filtered observation text.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
var examine_result: Variant = null
var raw_examine: Variant = raw.get("examine_result")
if raw_examine is Dictionary and raw_examine.has("text"):
examine_result = {
"entity_id": int(raw_examine.get("entity_id", 0)),
"text": str(raw_examine["text"]),
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
}
# v15: save_result (#554, D-085) — one-shot save/load operation result.
# {success: bool, kind: "save"|"load", error: String|null}
var save_result: Variant = null
var raw_save: Variant = raw.get("save_result")
if raw_save is Dictionary:
save_result = {
"success": bool(raw_save.get("success", false)),
"kind": str(raw_save.get("kind", "")),
"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
# can be removed — apply_snapshot() should contain only direct field assignments.
var stationary_ticks: Variant = null
var raw_st: Variant = raw.get("stationary_ticks")
if raw_st != null:
stationary_ticks = int(raw_st)
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
# When server populates this, client-side tile iteration fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var zone_id: Variant = null
var raw_zid: Variant = raw.get("zone_id")
if raw_zid is String:
zone_id = raw_zid
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
var player_knowledge: Variant = null
var raw_pk: Variant = raw.get("player_knowledge")
if raw_pk is Dictionary:
var kg_entities: Array = []
var raw_kg_entities: Variant = raw_pk.get("entities")
if raw_kg_entities is Array:
for raw_ke in raw_kg_entities:
if raw_ke is Dictionary and raw_ke.has("entity_id"):
kg_entities.append({
"entity_id": int(raw_ke["entity_id"]),
"name": str(raw_ke.get("name", "Unknown")),
"confidence": str(raw_ke.get("confidence", "Suspects")),
"source": str(raw_ke.get("source", "")),
"state": str(raw_ke.get("state", "Active")),
"relationship": str(raw_ke.get("relationship", "Unknown")),
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
})
var kg_facts: Array = []
var raw_kg_facts: Variant = raw_pk.get("facts")
if raw_kg_facts is Array:
for raw_kf in raw_kg_facts:
if raw_kf is Dictionary and raw_kf.has("fact_id"):
kg_facts.append({
"fact_id": str(raw_kf["fact_id"]),
"confidence": str(raw_kf.get("confidence", "Suspects")),
"source": str(raw_kf.get("source", "")),
"state": str(raw_kf.get("state", "Active")),
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
})
player_knowledge = {
"entities": kg_entities,
"facts": kg_facts,
}
return {
"tick": tick,
"entities": entities,
@@ -223,6 +327,13 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"pending_recognitions": pending_recognitions,
"conversation_events": conversation_events,
"conversation_ended": conversation_ended,
"poi_list": poi_list,
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
"debug_response": debug_response,
"stationary_ticks": stationary_ticks,
"zone_id": zone_id,
}
@@ -328,6 +439,18 @@ static func _decode_enum_variant(raw) -> Dictionary:
# -- Encode: GDScript types → bytes to server ----------------------------------
## Encode a StartupMessage to MessagePack bytes (#175).
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng with the world seed (D-010, D-029).
static func encode_startup_message(world_seed: int) -> PackedByteArray:
var msg := {"world_seed": world_seed}
var result = Messagepack.encode(msg)
if result.status != null:
push_error("Protocol: startup message encode failed: %s" % result.status)
return PackedByteArray()
return result.value
## Encode a PlayerInput to MessagePack bytes.
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
+327
View File
@@ -0,0 +1,327 @@
class_name TestHarness
extends RefCounted
## Standalone test simulation for client development without a running server.
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
## boundary (no game logic in the production client autoload).
var tick: int = 0
var player_pos: Vector2i = Vector2i(10, 10)
var facing: String = "North"
var input_queue: Array = []
var in_dialogue: bool = false
var gauntlet_mode: bool = false
var npc_relationship: String = "Unknown"
func reset() -> void:
tick = 0
player_pos = Vector2i(10, 10)
facing = "North"
input_queue.clear()
in_dialogue = false
gauntlet_mode = false
npc_relationship = "Unknown"
func process_input(action_name: String) -> void:
input_queue.append(action_name)
func process_facing(new_facing: String) -> void:
facing = new_facing
# -- Snapshot generation -------------------------------------------------------
func snapshot() -> Dictionary:
tick += 1
# Process queued inputs
for action_name in input_queue:
if action_name == "TeleportToHub":
player_pos = Vector2i(10, 10)
in_dialogue = false
continue
if action_name == "Interact":
var npc_pos := Vector2i(12, 9)
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
if dist <= 2 and has_los(player_pos, npc_pos):
in_dialogue = true
continue
var delta := action_to_delta(action_name)
var new_pos := player_pos + delta
if _is_walkable(new_pos):
player_pos = new_pos
if delta != Vector2i.ZERO:
if in_dialogue:
in_dialogue = false
input_queue.clear()
var px := player_pos.x
var py := player_pos.y
# Build entities — player always visible
var entities: Array = [{
"entity_id": 1,
"x": float(px),
"y": float(py),
"z": 0,
"kind": { "variant": "Player", "data": null },
"visibility": "Forward",
}]
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
var npc_pos := Vector2i(12, 9)
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
entities.append({
"entity_id": 2,
"x": float(npc_pos.x),
"y": float(npc_pos.y),
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
var nearby: Array = []
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
nearby.append({
"entity_id": 2,
"entity_type": "Npc",
"distance": npc_dist,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
],
})
# v5: monologue on first tick (#414)
var monologue: Variant = null
if tick == 1:
monologue = {
"id": "test_enter_001",
"text": "Sova Transit District. Population twelve thousand and change.",
"duration_seconds": 5.0,
}
# v7: mock dialogue (#435, D-061/D-062)
var dialogue: Variant = null
if in_dialogue:
dialogue = {
"npc_name": "Kael",
"npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
"options": [
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
],
}
# v7: mock pending_recognitions (#431, D-059/D-060)
var pending_recs: Array = []
var cycle_pos := tick % 12
if cycle_pos < 6:
var total_delay := 6
var remaining := total_delay - cycle_pos
pending_recs.append({
"entity_id": 100,
"x": 13.5,
"y": 12.5,
"z": 0,
"remaining_ticks": remaining,
"total_delay_ticks": total_delay,
})
# #535: Mock overheard NPC-NPC conversation (D-078)
var conv_events: Array = []
var conv_ended: Array = []
var conv_start := 3
var conv_lines := [
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
]
var conv_tick_interval := 5
var conv_total_ticks := conv_lines.size() * conv_tick_interval
if tick >= conv_start and tick < conv_start + conv_total_ticks:
var conv_index := (tick - conv_start) / conv_tick_interval
var within_tick := (tick - conv_start) % conv_tick_interval
if within_tick == 0 and conv_index < conv_lines.size():
var cl: Dictionary = conv_lines[conv_index]
conv_events.append({
"speaker_id": 10,
"target_id": 11,
"speaker_name": cl.speaker,
"target_name": cl.target,
"occluded_line": cl.line,
})
elif tick == conv_start + conv_total_ticks:
conv_ended.append({"speaker_id": 10, "target_id": 11})
return {
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {
"day": 0,
"time_of_day": tick * 10,
"day_phase": "Morning",
"tick_rate": "Full",
},
"player_facing": facing,
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"visible_tiles": _visible_tiles(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": gauntlet_mode,
"conversation_events": conv_events,
"conversation_ended": conv_ended,
"save_result": null,
}
# -- Map generation ------------------------------------------------------------
func _tiles() -> Array:
var tiles: Array = []
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(room_x, room_x + room_w):
for y in range(room_y, room_y + room_h):
var is_edge := (x == room_x or x == room_x + room_w - 1
or y == room_y or y == room_y + room_h - 1)
var tile_type: String
if is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
tile_type = "door"
else:
tile_type = "wall"
else:
tile_type = "floor"
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
var door_x := room_x + room_w / 2
for y in range(room_y + room_h, room_y + room_h + 4):
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
return tiles
func _visible_tiles() -> Array:
var vtiles: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 5
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:
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
return vtiles
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
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 -----------------------------------------------------------
const _WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
Vector2i(7,12), Vector2i(7,13),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
Vector2i(14,12), Vector2i(14,13),
# Interior wall blocking NPC
Vector2i(12, 10),
]
func _is_walkable(pos: Vector2i) -> bool:
return not _WALLS.has(pos)
func has_los(from: Vector2i, to: Vector2i) -> bool:
var dx := absi(to.x - from.x)
var dy := absi(to.y - from.y)
var sx := 1 if from.x < to.x else -1
var sy := 1 if from.y < to.y else -1
var err := dx - dy
var cx := from.x
var cy := from.y
while true:
if cx == to.x and cy == to.y:
return true
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
return false
var e2 := 2 * err
if e2 > -dy:
err -= dy
cx += sx
if e2 < dx:
err += dx
cy += sy
return true
static func action_to_delta(action_name: String) -> Vector2i:
match action_name:
"MoveNorth": return Vector2i(0, -1)
"MoveNortheast": return Vector2i(1, -1)
"MoveEast": return Vector2i(1, 0)
"MoveSoutheast": return Vector2i(1, 1)
"MoveSouth": return Vector2i(0, 1)
"MoveSouthwest": return Vector2i(-1, 1)
"MoveWest": return Vector2i(-1, 0)
"MoveNorthwest": return Vector2i(-1, -1)
_: return Vector2i.ZERO
static func delta_to_facing(delta: Vector2i) -> String:
match delta:
Vector2i(0, -1): return "North"
Vector2i(1, -1): return "Northeast"
Vector2i(1, 0): return "East"
Vector2i(1, 1): return "Southeast"
Vector2i(0, 1): return "South"
Vector2i(-1, 1): return "Southwest"
Vector2i(-1, 0): return "West"
Vector2i(-1, -1): return "Northwest"
_: return "North"
@@ -0,0 +1 @@
uid://2p33ypi2y2kv
+9 -2
View File
@@ -4,6 +4,9 @@ extends Node2D
## Reads textures from FogState autoload, positions rect to cover viewport.
## Architecture: docs/architecture/fog-shader-spec.md
signal fog_noise_ready
var _noise_ready: bool = false
var _fog_rect: ColorRect
var _shader_mat: ShaderMaterial
@@ -29,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()
@@ -36,10 +40,11 @@ func _ready() -> void:
noise_tex.width = 256
noise_tex.height = 256
noise_tex.seamless = true
noise_tex.changed.connect(func(): _noise_ready = true; fog_noise_ready.emit())
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
print("FogShader: Initialized (D-059 5-layer)")
print("FogShader: Initialized (D-059 3-state)")
func update_fog() -> void:
@@ -68,4 +73,6 @@ func update_fog() -> void:
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
var t: float = FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
_shader_mat.set_shader_parameter("time", t)
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
+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
+45
View File
@@ -0,0 +1,45 @@
class_name SnapshotEventRouter
## Routes snapshot fields to registered handlers (D-020, #559).
##
## Decouples main.gd from knowing which child node handles which snapshot field.
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
##
## Two handler types:
## - Keyed: called only when the snapshot contains a specific field.
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
##
## All handlers are zero-argument callables — they read from GameState directly.
## This preserves GameState as the single source of truth post-apply_snapshot().
## Keyed handlers: field_name → Array[Callable]
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
var _keyed: Dictionary = {} # String → Array[Callable]
## Always handlers: called every dispatch regardless of snapshot content.
var _always: Array[Callable] = []
## Register a handler for a specific snapshot field key.
## Handler is called (with no arguments) when snapshot.has(field) is true.
## Multiple handlers per field are supported — they run in registration order.
func register(field: String, handler: Callable) -> void:
if not _keyed.has(field):
_keyed[field] = []
_keyed[field].append(handler)
## Register a handler that runs every dispatch tick (not keyed to a field).
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
func register_always(handler: Callable) -> void:
_always.append(handler)
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
func dispatch(snapshot: Dictionary) -> void:
for handler in _always:
handler.call()
for field in _keyed:
if snapshot.has(field):
for handler in _keyed[field]:
handler.call()
@@ -0,0 +1 @@
uid://drppri4b46v80
-115
View File
@@ -1,115 +0,0 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
const VALUE_COLOR := Color("#c8d0e0")
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
var _cached_font: Font = null
func _ready() -> void:
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
return
queue_redraw()
func _draw() -> void:
if not visible:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
left_lines.append(["tick", str(GameState.current_tick)])
right_lines.append(["fps", str(Engine.get_frames_per_second())])
var pos := GameState.player_position
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
right_lines.append(["facing", GameState.player_facing])
left_lines.append(["stance", GameState.player_stance])
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
left_lines.append(["entities", str(GameState.visible_entities.size())])
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
var mono_status := "active" if GameState.current_monologue != null else "idle"
var dlg_status := "active" if GameState.dialogue_active else "idle"
left_lines.append(["monologue", mono_status])
right_lines.append(["dialogue", dlg_status])
left_lines.append(["stationary", str(GameState.stationary_ticks)])
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
var gt := GameState.game_time
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
left_lines.append(["time", time_str])
right_lines.append(["tick_rate", rate_str])
var mode_str := "test" if SimBridge.test_mode else "live"
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
var right_value_w: float = 0.0
for line in left_lines:
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
for line in right_lines:
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
var header_text := "F3 DEBUG"
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
+163
View File
@@ -0,0 +1,163 @@
class_name YamlParser
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
##
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
## (as UIStrings._parse_yaml() requires).
##
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
##
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
## Parse YAML text into a hierarchical Dictionary.
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
## Values are type-inferred: bool, int, float, or String.
static func parse(text: String) -> Dictionary:
var root: Dictionary = {}
# Stack: [{indent: int, key: String}] — path of open section headers
var stack: Array = []
# Array state
var current_array: Variant = null # Array being built, or null
var current_item: Variant = null # Dict being built for current array item, or null
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
for raw_line in text.split("\n"):
var stripped := raw_line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
var content: String = stripped.strip_edges()
# --- Array item (- key: value) ---
if content.begins_with("- "):
# First item: convert parent section's {} placeholder to []
if current_array == null and stack.size() > 0:
var parent := _node_at(root, stack, true)
var arr_key: String = stack.back()["key"]
var new_arr: Array = []
parent[arr_key] = new_arr
current_array = new_arr
array_parent_indent = stack.back()["indent"]
# Flush previous item and start a new one
if current_item != null:
current_array.append(current_item)
current_item = {}
var rest: String = content.substr(2).strip_edges()
var colon: int = rest.find(":")
if colon >= 0:
var k: String = rest.substr(0, colon).strip_edges()
var v: String = rest.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- Continuation line within current array item ---
if current_array != null and indent > array_parent_indent:
var colon: int = content.find(":")
if colon >= 0 and current_item != null:
var k: String = content.substr(0, colon).strip_edges()
var v: String = content.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- End of array (indent has returned to array level or above) ---
if current_array != null:
if current_item != null:
current_array.append(current_item)
current_item = null
current_array = null
array_parent_indent = -1
if stack.size() > 0:
stack.pop_back() # pop the array-owning key
# --- Regular key: value or section header ---
var colon: int = content.find(":")
if colon < 0:
continue
var key: String = content.substr(0, colon).strip_edges()
var val_str: String = content.substr(colon + 1).strip_edges()
# Pop sections at the same or deeper indent (we're back at a shallower level)
while stack.size() > 0 and stack.back()["indent"] >= indent:
stack.pop_back()
var node: Dictionary = _node_at(root, stack, false)
if val_str.is_empty() or val_str.begins_with("#"):
# Section header — create nested dict (may become Array if - items follow)
node[key] = {}
stack.push_back({"indent": indent, "key": key})
else:
node[key] = _parse_value(val_str)
# Flush the last array item if the file ended inside an array
if current_array != null and current_item != null:
current_array.append(current_item)
return root
## Convenience: parse text and flatten to dotted-key format in one call.
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
static func parse_flat(text: String) -> Dictionary:
return flatten(parse(text))
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
## {"a": {"b": "v"}} → {"a.b": "v"}
## Arrays are skipped — dotted-key format does not represent them.
## All values are converted to String (UIStrings stores display text, not typed data).
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
var result: Dictionary = {}
for k in d:
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
var v = d[k]
if v is Dictionary:
result.merge(flatten(v, full_key))
elif not v is Array:
result[full_key] = str(v)
return result
## Parse a single YAML value string into a typed GDScript value.
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comment outside quotes
if not val.begins_with("\""):
var comment_pos: int = val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string — extract content between quotes
if val.begins_with("\""):
var end_quote: int = val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true": return true
if val == "false": return false
# Float (must have decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
## Navigate root following the stack key path.
## parent=true: navigate one level less (returns the parent node, not the leaf).
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
var node: Dictionary = root
var depth: int = stack.size() - (1 if parent else 0)
for i in range(depth):
var k: String = stack[i]["key"]
if node.has(k) and node[k] is Dictionary:
node = node[k]
else:
break
return node
+1
View File
@@ -0,0 +1 @@
uid://cix55xks85vl8
+51 -45
View File
@@ -1,15 +1,21 @@
shader_type canvas_item;
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
// Layer 5: Unexplored, no maps — solid near-black #12141a
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
// 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;
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)
@@ -17,57 +23,57 @@ uniform vec2 map_offset; // map_bounds.position (tiles)
uniform vec2 map_size; // map_bounds.size (tiles)
uniform float tile_size; // Pixels per sim tile
uniform float time; // Seconds since start
uniform bool debug_exploration = false; // When true, render raw exploration texture
// D-059 fog layer colors
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
// D-059 thresholds (after bilinear filtering)
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
void fragment() {
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
// Map tile coordinate to texture UV
vec2 tex_uv = (tile - map_offset) / map_size;
// Outside known map → unexplored
// Outside known map -> unexplored
// Note: no early return — fragment() in OpenGL3 compat doesn't support return.
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 with filter_linear.
// If hardware bilinear works, should show smooth sub-tile gradients.
} else if (debug_exploration) {
float explored_raw = texture(exploration_tex, tex_uv).r;
COLOR = vec4(vec3(explored_raw), 0.9);
} else {
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (vis > PERIPHERAL_LOW) {
// In or near vision cone
if (vis > CLEAR_THRESHOLD) {
// Layer 1: Clear — soft edge gradient
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
} else {
// Layer 2: Light fog (peripheral + forward edge)
// D-059: animated Perlin noise, 8-10s cycle
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
COLOR = vec4(DARK_OVERLAY, alpha);
}
} else if (explored > 0.3) {
// Layer 3: Deep fog (previously explored, no longer in LOS)
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
COLOR = vec4(tint_color, alpha);
} else {
// Layer 5: Unexplored, no maps — information zero
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
} else {
// 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
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
// 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);
// 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);
COLOR = vec4(color, alpha);
}
}
}
+1
View File
@@ -0,0 +1 @@
う
@@ -0,0 +1 @@
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
@@ -0,0 +1 @@
うtickヲactionゥMoveNorth
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://qlxaaip3fqic"
path="res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/cursor_menu.png"
dest_files=["res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://oqbx8gwq1xta"
path="res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/dialogue_open.png"
dest_files=["res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2hctv70ar7al"
path="res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/dialogue_with_monologue.png"
dest_files=["res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bw4kxsno66sv3"
path="res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_3state.png"
dest_files=["res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://7ol3rcrpienc"
path="res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_boundary.png"
dest_files=["res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://fpncie7mnyay"
path="res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_boundary_replay.png"
dest_files=["res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://71e1yuyjdxn7"
path="res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_debug.png"
dest_files=["res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2owpogcjnewr"
path="res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_diagonal.png"
dest_files=["res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dufvsap028rfw"
path="res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_live_hub.png"
dest_files=["res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmqmnfrjrk1u"
path="res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_live_replay.png"
dest_files=["res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgdt2rmql6hbb"
path="res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_theater_replay.png"
dest_files=["res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8weq3wp6tybx"
path="res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/fog_zone_tint.png"
dest_files=["res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c5r4tk4yafj2r"
path="res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/hud_default.png"
dest_files=["res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0pym2n4nq12a"
path="res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/minimap_stance.png"
dest_files=["res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cc5nmdpumq6t"
path="res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/golden/visual/npc_in_fog.png"
dest_files=["res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env -S godot -s
## gdUnit4 CI runner for the Settled Reach client.
##
## Run all client tests headlessly:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/
##
## Run a specific test file:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
##
## Exit code: 0 = all pass, non-zero = failures.
##
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
## Test output format: JSON summary per D-030 sub-decision #6.
extends SceneTree
var _cli_runner: GdUnitTestCIRunner
func _initialize() -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
_cli_runner = GdUnitTestCIRunner.new()
root.add_child(_cli_runner)
func _finalize() -> void:
queue_delete(_cli_runner)
+1
View File
@@ -0,0 +1 @@
uid://ypniv2crdp8c
+2 -2
View File
@@ -250,7 +250,7 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
# Timer lifecycle, personal bests, room change, visibility.
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
# Spec: sprint-9/client.md #496. Ticket: tooling/db/ticket show 496.
func test_format_time_zero() -> void:
# Static utility: 0 seconds → "00:00"
@@ -476,7 +476,7 @@ func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
# -- BugReportDialog Feature Tests (#495) ------------------------------------
# Pause/unpause lifecycle, wire guard, text render, state machine.
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
# Spec: sprint-9/client.md #495. Ticket: tooling/db/ticket show 495.
func test_bug_report_sends_pause_on_open() -> void:
# start_capture() must send Pause to the server.

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