Compare commits

...
59 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 f7e5b7eb63 chore(meta): release v0.1.11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:13:56 +01:00
jpmschweitzerandClaude Opus 4.6 6cd82f1c63 chore(db): backup database after worktree merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:10:43 +01:00
jpmschweitzer f4548d407a Merge remote-tracking branch 'origin/planning' 2026-02-19 13:10:30 +01:00
jpmschweitzer 6ef239fd6e Merge remote-tracking branch 'origin/audio' 2026-02-19 13:10:26 +01:00
jpmschweitzerandClaude Opus 4.6 21b7db9ea5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 b3273599a5 docs(sprints): add Sprint 12 Build briefings
50 tickets across 5 teams: server (tier system, KG boundaries,
sound events), client (audio, fog indicators), copy (all v0.1
content), visual (grammar doc, placeholder spec), ci (tooling).
Sprint goal: production-layer foundations + complete v0.1 copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:30 +01:00
jpmschweitzerandClaude Opus 4.6 84b6e259bc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:52:09 +01:00
jpmschweitzerandClaude Opus 4.6 f0bab3999d chore(skills): rename all skills to domain-action convention
Adopts the whatsinagame naming pattern where the domain comes
first and the action second (e.g. pr-review, sprint-start,
audio-gen). Updated all cross-references in settings, agents,
docs, and inter-skill references.

12 renames: commit→git-commit, create-skill→skill-create,
gen-audio→audio-gen, gen-image→image-gen, plan-sprint→sprint-plan,
push-pr→pr-push, render-sprite→sprite-gen, review-pr→pr-review,
search-docs→docs-search, start-sprint→sprint-start,
start-workshop→workshop-start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:51:56 +01:00
jpmschweitzer 324caab244 Merge remote-tracking branch 'origin/client' 2026-02-19 12:37:28 +01:00
jpmschweitzer 8e879b340a Merge remote-tracking branch 'origin/server' 2026-02-19 12:37:25 +01:00
jpmschweitzerandClaude Opus 4.6 258b266f15 fix(simulation): address PR #39 review — 3 warnings + 6 suggestions
Warnings fixed:
- contraband.rs: scan event now always emits even when NPC already
  knows (was skipped by early `continue`). Contract matches doc.
- test_world/mod.rs: ScanEventBuffer added to player spawn bundle
  so check_contraband_scan doesn't silently no-op in gauntlet mode.
- npc/mod.rs → simulation/mod.rs: moved check_contraband_scan
  registration to SimulationPlugin (operates on player inventory and
  snapshot pipeline, consistent with process_talk_interaction).

Suggestions addressed:
- cross_room_transitions.rs T1: clarified standalone position vs
  constants.rs observer position in comment.
- dialogue.rs: Vec<&str> dedup replaced with BTreeSet<&str> for
  deterministic iteration (project convention).
- contraband.rs: added test for multiple simultaneous ScanAuthority
  NPCs in range (564 tests total).
- dialogue.rs: doc-comment on relationship_to_trust explaining
  KnowledgeConfidence ordering and Suspects default.
- cross_room_transitions.rs T5: noted direct KG API usage vs full
  perception system.
- sprint_gauntlet.rs: documented intentional Contentment { level: 0 }.
- content_scaling.rs: noted GAUNTLET_NPC_COUNT is manually maintained.
- contraband.rs: doc-comment on cross-plugin registration rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:23:02 +01:00
jpmschweitzerandClaude Opus 4.6 44d9941f04 fix(client): address PR #40 review — 13 suggestions
- Rename _insert to insert_state in main.gd (Hoshe #1)
- Add after_each() to test_bug_report_ring_buffer.gd for GameState
  cleanup on assertion failure (Hoshe #2)
- Add after_each() to test_insert_off_behavior.gd for stance/interaction
  restore on assertion failure (Hoshe #3)
- Fix assertion message: "unknown" → "unavailable" (Hoshe #4)
- Document memory ceiling of 60 JSON snapshots in ring buffer (Hoshe #5)
- Add precision warning for u64 rng_seed via JSON float (Hoshe #6)
- Promote _action_enum_to_wire to public action_enum_to_wire (Tyre #1)
- Add @warning_ignore for unused _tick parameter (Tyre #5)
- Document insert_active assumption for future no-insert characters (Tyre #4)
- Restructure OQ-07 decision amendments as bullet points (Tyre #7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:19:41 +01:00
jpmschweitzerandClaude Opus 4.6 590041a1bb chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:46 +01:00
jpmschweitzerandClaude Opus 4.6 f8ae36275d chore(config): add .tmp/ repo directory for agent temp files
Gitignored .tmp/ avoids Bash permission prompts when writing
temp files for tea comment posting. Added sed -n blanket
permission. Updated review-pr skill to use Write tool into
.tmp/ instead of Bash heredocs to /tmp/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:34 +01:00
jpmschweitzerandClaude Opus 4.6 cf8a4922e5 docs(client): sprint 11 test plans and reports
Test plan for #507/#522, preliminary review of #522, and final
combine test report. All tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:52 +01:00
jpmschweitzerandClaude Opus 4.6 2d9bc33760 test(client): add tests for ring buffer and insert-off behavior (#507, #522)
33 tests: 17 for ring buffer (capacity, overwrite, JSONL format, seed),
16 for insert-off behavior (diegetic test, cursor transitions, cross-system).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:47 +01:00
jpmschweitzerandClaude Opus 4.6 c96f1463dc feat(client): WRONG button 60-tick ring buffer captures (#507)
Upgrades bug_report_dialog.gd from single-tick MVP to 60-tick rolling
history. Pre-allocated ring buffers for inputs and snapshots. Outputs
inputs.jsonl (replay-compatible), snapshots.jsonl, and seed.txt on F12.
Inter-frame input accumulation ensures no inputs lost between server ticks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:42 +01:00
jpmschweitzerandClaude Opus 4.6 931399f248 feat(client): resolve OQ-07 — insert-off suppresses verb labels (#522)
Option (a): cursor shape still changes (body orients to targets), but
verb labels and interaction prompts are suppressed when insert_active
is false. Amends D-056 and D-057 with resolution note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:36 +01:00
jpmschweitzerandClaude Sonnet 4.6 bee93963d9 test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)
Cross-room transition tests (server/tests/cross_room_transitions.rs):
- T1: sprint suppresses interaction buffer, restores on Walk (D-055)
- T2: CarriedBy survives room transition — no TilePosition leak (D-065)
- T3: pause mid-corridor discards movement, Unpause resumes (D-031)
- T4: KnowledgeGraph persists across player position change (D-041)
- T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060)
- T6: eavesdrop cut immediately on first movement out of corner (D-071)
- T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070)
- T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071)

All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint).
Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin.

Test suite expansion:
- content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513)
- golden/proof_room_tick_10.json: updated golden file for gauntlet world changes
- golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to
  new world entity count and wire types
- gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:04:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 d103e445e7 feat(simulation): contraband detection + dialogue confidence gate (D-075) (#425, #524)
Contraband detection (Task #425, D-065):
- New module: simulation/contraband.rs — NPC scan checks carried items
  against the KnowledgeGraph confidence gate. NPCs with Authority access
  can initiate a scan; scan outcome depends on item CarriedBy + KG entry.
- Adds ContrabandScanResult event type and ContrabanEntry component.
- Wired into simulation/mod.rs module list.

Dialogue confidence gate (Task #524, D-075 — OQ-18 resolution):
- relationship_to_trust() gains confidence parameter (KnowledgeConfidence).
- Trust tier mapping: (Friendly, KnowsDetails+)→Secret, (Friendly|Known,
  KnowsOf+)→Real, otherwise Surface. Access tier (Layer 1) unchanged.
- Caller process_talk_interaction passes observer KG confidence_of target.
- Resolves OQ-18: confidence co-gates TrustTier, not AccessTier.

Supporting changes:
- decisions/content.md: add D-075 (16 decisions, dated 2026-02-19)
- knowledge/types.rs: expose KnowledgeConfidence comparison helpers
- knowledge/registry.rs: minor API polish
- bridge/types.rs: ContrabandScanResult wire type
- bridge/text_renderer.rs: render contraband scan status
- perception/observer: include carried item count in snapshot
- npc/mod.rs: NPC scan range constant, authority flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:12:24 +01:00
jpmschweitzerandClaude Sonnet 4.6 54754e4080 feat(simulation): add Sprint 11 gauntlet rooms — Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage (#504)
Adds three new test rooms to the gauntlet layout at coordinates that tile
correctly with the existing Sprint 11 world. All rooms follow the canonical
entity-registration pattern (StableId 55-62 assigned in spawn order).

- sprint_gauntlet.rs: 32×22 room (StableId 55-57) — Npc_pacing, Npc_guard,
  Readable sign. Validates sprint suppression (D-055) at close range.
- eavesdrop_alcove.rs: 24×16 room (StableId 58-60) — two NPC speakers,
  corner Readable marker. Validates eavesdrop positioning (D-071).
- confrontation_stage.rs: 32×24 room (StableId 61-62) — Npc_target and
  peripheral passer-by NPC. Validates confrontation verb range (D-070).

Updated test_world: mod.rs registers all new rooms, constants.rs adds GAUNTLET
region constant and room spawn points, reset.rs clears all gauntlet rooms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 6f9a53cb1f docs(briefings): add Sprint 11 Combine briefings
Server (5 tickets), client (2 tickets), and joint coordination
briefings for the Gauntlet completion sprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:54:08 +01:00
jpmschweitzerandClaude Opus 4.6 e2d2211ff6 chore(meta): release v0.1.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:13:49 +01:00
jpmschweitzer 62d48dc7b4 Merge remote-tracking branch 'origin/client' 2026-02-19 09:10:37 +01:00
jpmschweitzerandClaude Opus 4.6 7640a9ab87 fix(client): address PR #38 review — 6 warnings + 4 suggestions
Hoshe:
- COLOR_FADE_DURATION 0.7 → 0.5 to match D-033 spec ("0.5s fade")
- Gauntlet guard tests now exercise InputMapper._unhandled_input()
  with synthesized InputEventKey instead of asserting a bool
- Buffer clearing tests use SimBridge pipeline instead of manual nulls
- Add mid-transition re-trigger test (rapid relationship changes)
- Add relationship field to test snapshot NPC

Tyre:
- Add _teleport_in_progress flag to defer smoothing re-enable by one
  frame after teleport (prevents same-_process() re-enable race)
- Add _test_gauntlet_mode to SimBridge test snapshot
- Extract TELEPORT_DISTANCE_THRESHOLD constant, mirror in tests
- Add comments: flash preemption, modulate/color independence
- Rename "hub teleport" → "Gauntlet dev teleport" in code comments
  to clarify this is not production fast-travel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 6d99f9bc8c docs(decisions): add Q-027 fast-travel system design
Re-scope #501 hub teleport as Gauntlet-only dev tool. Production
travel must be diegetic: local gate → system gate → target menu →
destination, gated by region safety, with asymmetric information
exposure (NPCs observe arrivals/departures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:03:16 +01:00
jpmschweitzerandClaude Opus 4.6 58e592fd5e feat(client): confrontation D-033 color shift (#521, D-063)
Entity renderer now tracks relationship per entity and tweens D-033
tint color over 0.7s when relationship changes (e.g. on confrontation
delivery). Uses manual lerp in _process() for testability instead of
SceneTree tweens. Cursor hover tint cascades automatically via
Constants.color_for_entity_kind().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:12 +01:00
jpmschweitzerandClaude Opus 4.6 7274151671 feat(client): hub teleport UX — Home key, fade transition (#501)
Home key sends TeleportToHub in Gauntlet mode. Camera snaps to hub
spawn with 0.3s fade-from-black. Clears dialogue/monologue buffers
on teleport. Teleport detection uses distance threshold (>5 tiles)
so future teleport types get the transition for free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:02 +01:00
jpmschweitzer fd2101368e Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:35:15 +01:00
jpmschweitzerandClaude Opus 4.6 503644beb1 fix(client): address PR #36 re-review — 7 items from Hoshe + Tyre
Warnings: facing indicator tests use Godot-normalized rotation range
(-PI, PI] instead of raw addition (SW/W/NW in test_rendering,
West in test_client_p3). Suggestions: cache font in world_radial
_draw(), fix docstring on deactivate_insert() trigger, document
tile-coordinate system on _eval_player_near (D-066), add public
reset_facing_state() to InputMapper (D-030 testability).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:28:37 +01:00
jpmschweitzer c6b4017621 Merge remote-tracking branch 'origin/server' 2026-02-18 18:26:27 +01:00
jpmschweitzer 54f43ee58c Merge remote-tracking branch 'origin/ci'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:26:23 +01:00
jpmschweitzerandClaude Opus 4.6 52eb8bfc81 fix(simulation): address PR #37 re-review — stale strings, test coverage, confrontation symmetry
Hoshe re-review (3 items):
- content_scaling.rs:185: doc "StableId 0-51" → references constant
- content_scaling.rs:256: assertion message "id<=51" → "id <= max_baseline_id"
- input.rs: teleport test now asserts WalkAwayRequest + ConfrontationDelivered
  are cleared (was only checking TalkRequest + ActiveDialogue)

Tyre re-review (2 items):
- input.rs: same teleport test coverage (overlaps Hoshe #3)
- dialogue.rs: process_confrontation_response now inserts RoutineDeviation
  with DeviationTrigger::Confrontation — symmetric with walk-away path.
  Test updated to verify deviation is recorded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:22:32 +01:00
jpmschweitzerandClaude Opus 4.6 d92a2e5f50 fix(simulation): address PR #37 review — doc corrections, race fix, marker cleanup
Hoshe review (4 items):
- types.rs: doc comment "Current: 6" → "Current: 9"
- dialogue.rs: walk-away doc duplicated numbering (items 4-5 were 2-3)
- test_world/mod.rs: comment "Reset plates at 49-51" → "49-55"
- content_scaling.rs: magic number 51 → constants::RESET_PLATE_STABLE_IDS.1

Tyre review (3 items):
- knowledge/types.rs: guard comments on decrement() floor at Hostile
- input.rs: TeleportToHub now clears ConfrontationDelivered marker
- content_scaling.rs: same magic number fix (covered above)

Additional:
- content_runtime.rs: barrier-based shutdown handshake fixes TCP RST
  race condition under parallel test execution
- dialogue_room.rs: clippy type_complexity allow on NPCS tuple array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:50:22 +01:00
jpmschweitzerandClaude Opus 4.6 8d8da1b546 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:23 +01:00
jpmschweitzerandClaude Opus 4.6 d008874c14 chore(skills): add commit step to push-pr workflow
push-pr now runs /commit first when uncommitted changes are detected,
making it a one-stop commit→push→PR command. Added Skill to
allowed-tools for cross-skill invocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:11 +01:00
jpmschweitzerandClaude Opus 4.6 7282c46db0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:01 +01:00
jpmschweitzerandClaude Opus 4.6 b95e82d17f chore(skills): update gen-audio skill with batch workflow and --post docs
Documents audio-batch manifest format, synth parameters, and the
--post flag as the preferred workflows. Batch reduces approval
count from ~30 to 2 for multi-asset generation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:38 +01:00
jpmschweitzerandClaude Opus 4.6 9f7214dcd6 feat(db): add audio-batch processor and --post flag to audio-generate
Reduces prompt approvals from ~30 per batch to 2 (one manifest write,
one batch run). audio-generate --post chains trim+normalize+convert
after generation. audio-batch processes a JSON manifest of multiple
assets sequentially, supporting both SAO generation and harmonic
synthesis methods. Includes --dry-run, --only, and --skip-existing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:33 +01:00
jpmschweitzerandClaude Opus 4.6 919ef39cbd fix(client): address PR #36 review — 10 items from Hoshe + Tyre
Critical:
- deactivate_insert() now called when selecting non-Insert spoke,
  cancelling with no selection, or pressing Escape while insert is
  active. Fixes simulation staying paused permanently after Insert.

Warnings:
- Checklist conditions with empty id excluded from get_results() and
  get_total_count() — prevents impossible-to-complete checklists.
  Warns at load time when empty-id conditions are found.
- _content_base now checks res://content/ first (exported builds),
  falls back to ../content for editor/dev mode.
- 26 new tests for D-054 functions: _angle_to_octant (8 octants),
  _snap_to_octant_dir (9 cases incl. zero/tiny), _wasd_to_world_dir
  (8 facing/movement combos). New test file: test_input_mapper_facing.gd.

Suggestions:
- Cached get_theme_default_font() in checklist overlay _ready().
- Documented InputMapper → GameState coupling as intentional.
- Documented YAML parser # truncation limitation.
- _insert_active reset on Escape dismiss (Tyre #3).
- SimBridge test mode SetFacing reads action_data.facing instead of
  InputMapper global (Tyre #4).
- Removed dead _facing_to_rotation() from entity_renderer.gd (Tyre #5).
- Fixed 2 failing facing indicator tests to use InputMapper.facing_angle
  instead of GameState.player_facing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:09:38 +01:00
jpmschweitzerandClaude Opus 4.6 d0596d2763 fix(ci): address PR #35 review comments
- Document non-blocking receive contract in perf_bench.rs docstring,
  confirming no TCP deadlock race (Hoshe #1, critical)
- Make shadowcast parser order-independent — flush on new config header
  instead of requiring Recursive after Symmetric (Hoshe #2)
- Fix p95 calculation: use floor(0.95*(N-1)) nearest-rank instead of
  ceil(0.95*N)-1 which was off-by-one at N=50 (Hoshe #4)
- Error on --compare when no baseline file exists (Hoshe #5)
- Add D-031 10tps assumption comment to TICK_BUDGET_US (Tyre #1)
- Strengthen snapshot assertion: require warmup + half measurement
  window instead of warmup + 1 (Tyre #2)
- Regenerate baseline with corrected p95 (356µs, was 526µs)

Hoshe #3 (.PHONY) was already addressed — perf-baseline is in the
.PHONY declaration on Makefile line 10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:07:46 +01:00
jpmschweitzerandClaude Opus 4.6 6c62e2228f test(simulation): sprint 10 — replay loading, content scaling, serialization v9, observer tests
#483: Replay loading in test-client — JSONL file loading, tick-scheduled
PlayerInput sending, 13 unit tests, 3 sample replay files.
#500: Content scaling test — baseline + extra NPC comparative, tick budget
assertion (D-026), determinism check across content packs.
#514: Serialization tests for protocol v9 — blocked_entities roundtrip,
backward compat (v5→v9, v8→v9), regenerated msgpack fixtures.
Observer perception tests for confrontation + walk-away mechanics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:27 +01:00
jpmschweitzerandClaude Opus 4.6 273d29f26f feat(simulation): sprint 10 — hub teleport, confrontation response, walk-away phase 2, gauntlet rooms 4-7, blocked_entities debug field
#491: PlayerAction::TeleportToHub — moves player to hub spawn, clears
dialogue/monologue/interaction buffer, Gauntlet-only with log warning.
#520: ConfrontationDelivered event — Tier 2 animation shift, relationship
state decrement (D-033), monologue spike emission.
#519: Walk-away Phase 2 — NPC animation shift + routine deviation on
dialogue exit (D-064).
#498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd
Plaza, Dialogue Room with constants and wiring.
#514: blocked_entities Vec<u64> on ObserverSnapshot, protocol v9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:14 +01:00
jpmschweitzerandClaude Opus 4.6 a3a311b3bd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:41 +01:00
jpmschweitzerandClaude Opus 4.6 cfbfa332a2 docs(audio): update asset documentation for Sprint 10 deliverables
Updated ambient.md, sfx.md, and ui.md with generation parameters,
frequency specs, and integration notes for all 8 new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:14 +01:00
jpmschweitzerandClaude Opus 4.6 b86457f257 feat(audio): add 8 minimum viable audio assets (#327)
4 ambient loops (SAO, organic aesthetic, ~43s crossfade loops):
  amb_station_base, amb_workplace_layer, amb_bar_layer, amb_corridor_layer
2 footsteps (SAO hybrid, organic aesthetic, 0.3s):
  sfx_footstep_metal_walk, sfx_footstep_metal_run
2 monologue chimes (manual synthesis, insert-tech aesthetic, 0.8s):
  sfx_monologue_chime, sfx_monologue_chime_urgent

Chimes replace Sprint 9 SAO placeholders with production-quality
manual synthesis per D-074. All assets LUFS -16 normalized.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:07 +01:00
jpmschweitzerandClaude Opus 4.6 ab85fbd652 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:50 +01:00
jpmschweitzerandClaude Opus 4.6 eb1d89ea86 feat(client): room reset UX, insert pause wiring, auto-checklist (#502, #518, #503)
Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s
screen flash on room_reset monologue, 'Reset Room' verb via existing
nearby_interactions.

Insert pause (#518, D-058): explicit PauseSimulation on insert open,
ResumeSimulation on close. Replaces toggle-style pause with idempotent
pair per D-058.

Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates
7 condition types against GameState with latching. ChecklistOverlay
renders progress in gauntlet mode only. 48 tests covering parser,
evaluation, latching, visibility, and integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:35 +01:00
jpmschweitzerandClaude Opus 4.6 97cb69e6a4 feat(client): mouse-relative facing and movement (#526, D-054)
Mouse position now determines facing direction as a client-side float.
WASD remapped: W=toward cursor, S=away, A/D=strafe. Facing octant
derived from mouse angle and sent to server via SET_FACING action only
when it changes. EntityRenderer facing indicator uses continuous angle
for smooth rotation. SimBridge test mode updated to handle SetFacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:19 +01:00
jpmschweitzerandClaude Opus 4.6 80d5a57b5d feat(client): introduce Michroma font and implant UI theme (#517)
Add Michroma-Regular.ttf (OFL license) as the game font. Create
FontVariation with +1px tracking and a global Theme resource with
cyan-white (#E0F7FA) implant text color. Wire theme into project.godot
so all Label/RichTextLabel/Button/LineEdit nodes inherit automatically.
Add IMPLANT_TEXT_COLOR/DIM/PULSE constants to constants.gd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:08 +01:00
jpmschweitzerandClaude Opus 4.6 370649b3df chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:00 +01:00
jpmschweitzerandClaude Opus 4.6 f13aafb1dd docs(meta): add asset pipeline and remote terminal proxy ideas to scratchpad
Two new scratchpad entries: asset generation pipeline improvements
(registry, status tracking, prompt versioning) and a remote terminal
proxy concept for mobile monitoring of Claude Code permission prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:52:49 +01:00
jpmschweitzerandClaude Opus 4.6 c1d7c072ae chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:44:34 +01:00
jpmschweitzerandClaude Opus 4.6 08052c6459 feat(ci): add performance baseline tooling (#499)
Adds `make perf-baseline` — boots the full server plugin stack with
real content, measures 50 ticks (5 warmup), captures per-tick timing,
entity counts, and process RSS. Includes shadowcast benchmarks. Saves
structured JSON to tests/perf/baseline.json for regression detection.
Supports --compare mode (>20% threshold).

First baseline: mean 366µs, p95 526µs (0.5% of D-026 100ms budget).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:42:09 +01:00
jpmschweitzerandClaude Opus 4.6 76efc979b9 fix(ci): correct bidirectional relationship check target (#515)
Check 9 tested `target in npc_rels` which missed NPCs with no
relationship entries. Changed to `target in self.npcs` so the
advisory warning fires correctly for all NPCs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:41:35 +01:00
jpmschweitzerandClaude Opus 4.6 c2a3a251b4 feat(skills): start-sprint main workflow as state machine
On main, the skill now assesses sprint state and does the next right
thing: close active sprint (Case A), activate planned sprint (Case B),
or suggest /plan-sprint (Case C). Cases chain automatically — closing
flows into activation if a planned sprint is waiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:15:56 +01:00
jpmschweitzerandClaude Opus 4.6 b4a3784218 docs(sprints): add Sprint 10 Prove briefings
Server (7), client (7), CI (3), audio (1) — 18 tickets total.
Gauntlet expansion, audio wiring, client UX polish, CI pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:12:45 +01:00
jpmschweitzerandClaude Opus 4.6 407018ecdf chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 973c804b52 feat(meta): add project.yaml as version source of truth
Technical project descriptor with game architecture, simulation
parameters, and content model. Version field (0.1.9) is now the
canonical version — Cargo.toml synced, CLAUDE.md and start-sprint
skill updated to reference it during release workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:29 +01:00
159 changed files with 11438 additions and 522 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ Synthesize findings.
### Qatux (Documenter & Librarian)
- Core team member — participates in discussion rounds as documenter
- Manages document search via `/search-docs` skill
- Manages document search via `/docs-search` skill
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
- Answers "did we discuss this?" with citations
+1 -1
View File
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
- Provide "state of the project" summaries when asked
### Knowledge management
- Maintain the Qdrant document index via /search-docs skill
- Maintain the Qdrant document index via /docs-search skill
- Update briefing files when decisions change
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
- Catch staleness in briefings and flag for update
+3 -2
View File
@@ -50,10 +50,11 @@
"Bash(find *)",
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Skill(commit)",
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(start-sprint)"
"Skill(sprint-start)"
],
"deny": [
"Bash(git push --force *)",
+255
View File
@@ -0,0 +1,255 @@
---
name: audio-gen
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate a single asset (WAV only)
db/connectors/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" \
--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
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
| `--post` | off | — | Run trim + normalize + convert after generation. |
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Batch Workflow (Preferred)
For generating multiple assets, use a manifest file. This reduces prompt
approvals to 2: one Write (manifest) + one Bash (batch run).
### 1. Create the manifest
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
```json
{
"description": "Sprint 10 ambient + world SFX batch",
"output_dir": "client/assets/audio",
"gen_dir": "client/assets/audio/gen",
"defaults": {
"steps": 100,
"cfg": 7,
"lufs": -16,
"quality": 6
},
"assets": [
{
"id": "AMB-001",
"filename": "amb_station_base.ogg",
"method": "sao",
"duration": 45,
"steps": 150,
"cfg": 5,
"prompt": "[sonic family prefix] + [template] + [description]"
},
{
"id": "UI-005",
"filename": "sfx_monologue_chime.ogg",
"method": "synth",
"synth": {
"type": "harmonic",
"duration": 0.8,
"fundamental": 1200,
"harmonics": [
{"freq": 2400, "db": -12},
{"freq": 3600, "db": -24}
],
"attack_ms": 15,
"sustain_ratio": 0.2,
"decay": "exponential"
}
}
]
}
```
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
### 2. Run the batch
```bash
# Full run
db/connectors/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
# Generate only specific assets
db/connectors/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
```
### 3. Update asset docs with prompts
After the batch completes, write the exact prompts used back into the
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
what was generated; the asset docs record what we have.
### Manifest fields
| Field | Required | Notes |
|-------|----------|-------|
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
| `filename` | yes | Output filename (must match asset doc) |
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
| `duration` | SAO only | Duration in seconds |
| `prompt` | SAO only | Full assembled prompt |
| `steps` | no | Override default steps |
| `cfg` | no | Override default CFG |
| `synth` | synth only | Synthesis parameters (see below) |
### Synth parameters
| Field | Default | Notes |
|-------|---------|-------|
| `type` | harmonic | Only `harmonic` supported currently |
| `duration` | — | Duration in seconds |
| `fundamental` | — | Fundamental frequency in Hz |
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
| `attack_ms` | 10 | Attack time in milliseconds |
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
| `decay` | exponential | `exponential` or `linear` |
## Single Asset Workflow
For one-off generation or iteration on a specific asset:
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
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
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`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate (post-processing is already done if `--post` was
used).
## Post-Processing (Standalone)
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
# 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
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
or the batch manifest's `method: "synth"` with harmonic parameters.
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
impulse), write a custom script in `tooling/` following the pattern in
`tooling/synth_ui_sounds.py`.
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
AudioManager discovers assets by directory scan — filenames must match exactly.
@@ -1,9 +1,9 @@
---
name: search-docs
name: docs-search
description: >
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
-187
View File
@@ -1,187 +0,0 @@
---
name: gen-audio
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate audio
db/connectors/audio-generate "prompt text" \
--duration 10 \
--steps 100 \
--cfg 7 \
--output path/to/output.wav \
--timeout 600
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output file path. Auto-names from prompt if omitted. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
- **Output is WAV at 44.1kHz stereo.** Convert to .ogg for Godot import:
`ffmpeg -i input.wav -c:a libvorbis -q:a 6 output.ogg`
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Single Asset Workflow
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
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 the assembled prompt. **One request
at a time. Wait for completion.**
7. Listen to the output (or describe it based on file size/duration).
8. If acceptable, convert to .ogg and place in `client/assets/audio/`.
9. Update the asset status in `docs/assets/audio/{category}.md`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate, trim, normalize, convert.
## Post-Processing
After selecting the best generation:
```bash
# Trim silence from start/end
ffmpeg -i input.wav -af "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse" trimmed.wav
# LUFS normalize to -16 LUFS (broadcast standard, good for game audio)
ffmpeg -i trimmed.wav -af loudnorm=I=-16:LRA=11:TP=-1 normalized.wav
# Convert to .ogg for Godot
ffmpeg -i normalized.wav -c:a libvorbis -q:a 6 output.ogg
# For loops: verify loop point
ffplay -loop 0 output.ogg
```
For ambient loops, create crossfade overlap:
```bash
# Create a 45s loop with 3s crossfade overlap
# (manual: export 48s, crossfade first 3s with last 3s in Audacity)
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis instead:
```python
# Example: 50ms cursor hover tick
import numpy as np
import wave
sr = 44100
duration = 0.05 # 50ms
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
freq = 3200 # Hz
signal = np.sin(2 * np.pi * freq * t)
envelope = np.exp(-t * 80) # exponential decay
audio = (signal * envelope * 32767).astype(np.int16)
with wave.open("cursor_hover.wav", "w") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(sr)
f.writeframes(audio.tobytes())
```
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/palette.md)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs:
```
client/assets/audio/
amb_station_base.ogg # Ambient bus
amb_workplace_layer.ogg # Ambient bus
amb_bar_layer.ogg # Ambient bus
amb_corridor_layer.ogg # Ambient bus
sfx_footstep_metal.ogg # Player Actions bus
sfx_footstep_metal_run.ogg # Player Actions bus
cursor_hover.ogg # UI Sounds bus
implant_open.ogg # UI Sounds bus
fog_recognition.ogg # UI Sounds bus
weapon_aim.ogg # UI Sounds bus
sfx_monologue_chime.ogg # UI Sounds bus
sfx_monologue_chime_urgent.ogg # UI Sounds bus
```
AudioManager discovers these by directory scan — filenames must match exactly.
@@ -1,8 +1,8 @@
---
name: commit
name: git-commit
description: >
Commit changes with clean, structured messages. Use when the user says
"commit", "save my work", "commit changes", or invokes /commit. Enforces
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
conventional commit format, groups changes into logical commits, and maintains
CHANGELOG.md. Never squash unrelated changes into one commit.
user-invocable: true
@@ -1,5 +1,5 @@
---
name: gen-image
name: image-gen
description: >
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
@@ -1,14 +1,14 @@
---
name: push-pr
name: pr-push
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
---
# Push PR Skill
@@ -34,17 +34,29 @@ git branch --show-current
If on `main`, stop: "You're on main. Switch to a team branch first."
### 2. Check for unpushed commits
### 2. Commit uncommitted changes
```bash
git status
```
If there are uncommitted changes (staged or unstaged), run the **commit skill**
first. Use the `/git-commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
proceeding.
If the working tree is clean (no uncommitted changes), skip to step 3.
### 3. Check for unpushed commits
```bash
git fetch --all
git status
git log --oneline origin/<branch>..<branch>
```
If no unpushed commits, skip to step 4 (PR check).
If no unpushed commits, skip to step 5 (PR check).
### 3. Check for conflicts with main
### 4. Check for conflicts with main
```bash
git merge-tree --write-tree origin/main HEAD 2>&1
@@ -59,7 +71,7 @@ git merge origin/main --no-edit
If merge conflicts, **stop and report** — let the user resolve.
If clean, continue.
### 4. Push
### 5. Push
```bash
git push origin <branch>
@@ -67,7 +79,7 @@ git push origin <branch>
If push fails, stop and report. Never force-push without explicit request.
### 5. Check for existing PR
### 6. Check for existing PR
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
@@ -76,9 +88,9 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
Match current branch name in PR list.
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
- **No PR**: Continue to step 6.
- **No PR**: Continue to step 7.
### 6. Create a new PR
### 7. Create a new PR
```bash
git log --oneline main..<branch>
@@ -103,7 +115,7 @@ tea pr create \
Report PR URL when done.
### 7. Update ticket status to review
### 8. Update ticket status to review
Scan all commit messages in the PR for ticket references (`#NNN`):
@@ -124,5 +136,5 @@ already `done`, `review`, `cancelled`, or `backlog` (only transition
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
PR title instead of generating one.
@@ -1,9 +1,9 @@
---
name: review-pr
name: pr-review
description: >
Review a branch diff with team-appropriate agents before merge. Use when the
user says "review-pr", "review this PR", "review this branch", or invokes
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
audio) in parallel. Reports approve/reject with inline comments.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Task
@@ -156,19 +156,16 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
```bash
# Step 1: Write review to temp file (use Bash heredoc, NOT the Write tool)
cat > /tmp/review-<branch>.md << 'EOF'
## Review: <branch> -> main
...review content...
EOF
```
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
# Step 2: Post to Gitea (separate Bash call)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
```
These MUST be separate sequential Bash calls — do not chain them in a single
command. The heredoc write must complete before `tea comment` reads the file.
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
directory is gitignored and exists in the repo root for this purpose.
## 7. Merging approved PRs
@@ -1,5 +1,5 @@
---
name: create-skill
name: skill-create
description: >
Guidance for creating effective Claude Code skills (.skill packages).
Use when the user wants to create, build, design, or iterate on a skill —
@@ -1,9 +1,9 @@
---
name: plan-sprint
name: sprint-plan
description: >
Plan the next sprint and generate team briefing files. Use when the user says
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
proposes ticket selection, and writes briefing files per team to
docs/sprints/sprint-N/.
user-invocable: true
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
```
Task(
subagent_type: "si",
prompt: "Run /plan-sprint for Sprint N. Read the skill at
.claude/skills/plan-sprint/SKILL.md for the full workflow,
prompt: "Run /sprint-plan for Sprint N. Read the skill at
.claude/skills/sprint-plan/SKILL.md for the full workflow,
then execute it. Use the arguments provided: {args}",
description: "Plan sprint N"
)
@@ -1,12 +1,12 @@
---
name: start-sprint
name: sprint-start
description: >
Start sprint work on a team branch, or close a sprint from main. Use when
the user says "start sprint", "start working on the server/client/copy",
"begin sprint", or invokes /start-sprint. On a team branch: merges main,
finds the active sprint, reads the briefing, presents the work plan. On
main: closes the active sprint, bumps the version (v0.1.N), updates the
changelog, tags, and pushes.
Manage the sprint lifecycle from main, or start sprint work on a team
branch. Use when the user says "start sprint", "start working on the
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
@@ -32,12 +32,22 @@ the team branch workflow (steps 28).
---
## Main branch workflow (sprint close + version bump)
## Main branch workflow (sprint lifecycle management)
When `/start-sprint` is run on `main`, it means the user wants to close
the current sprint, cut a version, and prepare for the next one.
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:
### M1. Close the active sprint
```bash
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
#### A1. Close the active sprint
```bash
db/connectors/sprint stop
@@ -46,11 +56,17 @@ db/connectors/sprint stop
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
### M2. Bump the version
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `project.yaml`:
- Set the `version` field to `0.1.N` (this is the source of truth).
Update `server/Cargo.toml`:
- Set `version = "0.1.N"` in `[package]`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
@@ -58,32 +74,66 @@ Update `CHANGELOG.md`:
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
### M3. Commit the release
#### A3. Commit the release
Stage and commit `CHANGELOG.md`:
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
### M4. Tag the release
#### A4. Tag the release
```bash
git tag v0.1.N
```
### M5. Push
#### A5. Push
```bash
git push && git push --tags
```
### M6. Report
#### A6. Check for a planned sprint
Output a summary:
- Sprint closed (name, done/total tickets, carry-over count)
- Version tagged (`v0.1.N`)
- Carry-over candidates (if any)
- Suggest running `/plan-sprint` next to prepare the next sprint
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/sprint-plan`.
---
### Case B: No active sprint, but a planned sprint exists
A sprint is ready to activate. Verify it looks complete:
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
```bash
ls docs/sprints/sprint-N/
```
2. Check the ticket count:
```bash
db/connectors/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
```bash
db/connectors/sprint start
```
Then report:
- Sprint activated (name, ticket count per team)
- Remind the user to switch to a team branch and run `/sprint-start`
there (or `cd` into the relevant worktree)
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/sprint-plan`
to plan the next sprint.
---
@@ -117,7 +167,7 @@ If no active sprint is found, report that and stop.
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/plan-sprint` to generate one.
`/sprint-plan` to generate one.
### 5. Load ticket details
@@ -1,11 +1,11 @@
---
name: render-sprite
name: sprite-gen
description: >
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
east, south, west) with outline applied at working resolution. Use when the
user says "render sprite", "render model", "run the render pipeline",
"test the pipeline", "/render-sprite", or asks to render a specific model
"test the pipeline", "/sprite-gen", or asks to render a specific model
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
---
@@ -14,7 +14,7 @@ description: >
Run the render script with the model name:
```bash
.claude/skills/render-sprite/scripts/render.sh <model_name>
.claude/skills/sprite-gen/scripts/render.sh <model_name>
```
### Available Models
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
1. Create model scene at `renderer/models/<name>.tscn`
2. Apply texture from `renderer/textures/` via StandardMaterial3D
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
@@ -1,8 +1,8 @@
---
name: start-workshop
name: workshop-start
description: >
Start a multi-agent design workshop from a workshop brief. Use when the user says
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
Parses the workshop brief to extract participants, questions, and round format.
Creates a team, tasks, and spawns agents as teammates via the Task tool.
---
+1 -1
View File
@@ -73,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
Show a summary table of branches ahead of main. For each branch, indicate:
- `[PR]` if it has an open pull request — warn that it should go through
normal review channels (use `/review-pr` instead)
normal review channels (use `/pr-review` instead)
- Commit count and summary
Use `AskUserQuestion` to let the user pick which branches to merge.
+1
View File
@@ -1,5 +1,6 @@
# Build and cache
.cache/
.tmp/
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
+37
View File
@@ -6,6 +6,43 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.11] — 2026-02-19
### Added
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
- `sed -n` blanket permission in shared settings
### Changed
- All skills renamed to domain-action convention (e.g. `commit``git-commit`, `review-pr``pr-review`, `gen-audio``audio-gen`, `render-sprite``sprite-gen`) — 12 renames total
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
## [v0.1.10] — 2026-02-19
### Added
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
### Changed
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
- World radial menu uses theme font instead of ThemeDB.fallback_font
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
### Fixed
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
## [v0.1.9] — 2026-02-18
### Fixed
+2 -1
View File
@@ -3,7 +3,8 @@
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
**Official Title:** The Settled Reach (D-021)
**Repository name:** commonwealth (historical code name, retained for path stability)
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
## Project Structure
+6 -1
View File
@@ -6,7 +6,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
checklist-validate checklist-generate \
perf-baseline
# --- Configuration ---
@@ -44,6 +45,7 @@ help:
@echo " make golden-update Regenerate golden file and stage for commit"
@echo " make checklist-validate Validate checklist YAML against schema"
@echo " make checklist-generate Validate checklists + print condition summary"
@echo " make perf-baseline Run performance benchmarks and save baseline"
@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)"
@@ -280,6 +282,9 @@ checklist-validate:
checklist-generate:
@tooling/validate-checklist
perf-baseline:
@tooling/perf-baseline
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
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.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
[gd_resource type="FontVariation" load_steps=2 format=3]
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
[resource]
base_font = ExtResource("1_base")
spacing_glyph = 1
spacing_space = 1
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Theme" load_steps=2 format=3]
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
[resource]
default_font = ExtResource("1_font")
default_font_size = 14
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Label/font_sizes/font_size = 14
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
RichTextLabel/font_sizes/normal_font_size = 14
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
Button/font_sizes/font_size = 14
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
LineEdit/font_sizes/font_size = 14
+10
View File
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
theme/custom_font="res://assets/fonts/michroma_ui.tres"
[display]
window/size/viewport_width=1920
@@ -111,6 +116,11 @@ bug_report={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
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)
]
}
[rendering]
+7 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=20 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"]
@@ -17,7 +17,8 @@
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
[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"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -136,6 +137,9 @@ layer = 20
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
; #503: Auto-checklist overlay — condition progress in gauntlet mode
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -149,4 +153,4 @@ script = ExtResource("10_cursor")
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
+25
View File
@@ -40,6 +40,17 @@ var dialogue_active: bool = false
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
# Cursor shape changes still fire when false (D-056 option a).
# v0.1 assumption: always true — both playable characters (detective and smuggler)
# have neural inserts. Future characters without inserts would receive false from
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
var insert_active: bool = true
# #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
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -132,6 +143,20 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
room_id = null
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
# Server may send false for characters without an insert in future sprints.
if snapshot.has("insert_active") and snapshot.insert_active is bool:
insert_active = snapshot.insert_active
else:
insert_active = true
# #507: rng_seed — server sends current RNG seed for replay determinism.
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
if snapshot.has("rng_seed"):
rng_seed = snapshot.rng_seed
else:
rng_seed = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+123 -12
View File
@@ -3,7 +3,11 @@ extends Node
# Semantic actions — NO raw key codes cross the bridge
# Movement uses hold-to-move (polled each frame in _process).
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
# Composite diagonals: holding W+D simultaneously → northeast.
#
# D-054: Mouse-relative facing and movement.
# Mouse position determines facing direction (client-side float).
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
# Server receives facing octant only — the full float stays client-side.
#
# Movement throttle: client-side rate limit per stance (D-053).
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
@@ -15,10 +19,18 @@ 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
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
@@ -31,27 +43,44 @@ var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
return
var dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
dir.y -= 1
if Input.is_action_pressed("move_south"):
dir.y += 1
if Input.is_action_pressed("move_east"):
dir.x += 1
if Input.is_action_pressed("move_west"):
dir.x -= 1
if dir != Vector2i.ZERO:
# D-054: Send facing octant to server when it changes (even without movement)
if facing_octant != _last_sent_octant:
_last_sent_octant = facing_octant
input_queue.append({
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
})
# Poll held WASD keys
var raw_dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
raw_dir.y -= 1
if Input.is_action_pressed("move_south"):
raw_dir.y += 1
if Input.is_action_pressed("move_east"):
raw_dir.x += 1
if Input.is_action_pressed("move_west"):
raw_dir.x -= 1
if raw_dir != Vector2i.ZERO:
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec >= interval:
_last_move_msec = now
var action: Action = _dir_to_action(dir)
# D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir)
input_queue.append({
"action": action,
"timestamp_msec": now,
@@ -77,6 +106,9 @@ 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("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
if action != -1:
input_queue.append({
@@ -92,6 +124,85 @@ func flush_queue() -> Array[Dictionary]:
return queue
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0
facing_octant = "North"
_last_sent_octant = "North"
# D-054: Compute facing angle from mouse position relative to player screen position.
# Uses viewport canvas transform to convert world coords to screen coords.
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
var vp := get_viewport()
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var player_world_px := GameState.player_position * Constants.TILE_SIZE
var player_screen := canvas_xf * player_world_px
var mouse_screen := vp.get_mouse_position()
var delta := mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
facing_octant = _angle_to_octant(facing_angle)
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
# Forward = facing_angle direction. Output: nearest octant direction vector.
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
# Build a continuous direction vector relative to facing.
# raw_dir.y: -1 = W (forward), +1 = S (backward)
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
var forward := Vector2(cos(facing_angle), sin(facing_angle))
var right := Vector2(-forward.y, forward.x) # 90° clockwise
# Combine: forward/back from W/S, strafe from A/D
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
# Snap to nearest octant direction
return _snap_to_octant_dir(world_float)
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
if dir.length_squared() < 0.001:
return Vector2i.ZERO
var angle := dir.angle()
# Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return Vector2i(1, 0) # East
1: return Vector2i(1, 1) # Southeast
2, -6: return Vector2i(0, 1) # South
3, -5: return Vector2i(-1, 1) # Southwest
4, -4: return Vector2i(-1, 0) # West
-3, 5: return Vector2i(-1, -1) # Northwest
-2: return Vector2i(0, -1) # North
-1: return Vector2i(1, -1) # Northeast
_: return Vector2i.ZERO
# D-054: Convert a facing angle (radians) to the nearest octant name.
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return "East"
1: return "Southeast"
2, -6: return "South"
3, -5: return "Southwest"
4, -4: return "West"
-3, 5: return "Northwest"
-2: return "North"
-1: return "Northeast"
_: return "East"
# Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action:
+29 -6
View File
@@ -10,6 +10,8 @@ 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 _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -175,13 +179,22 @@ func send_input(player_input: Dictionary) -> Error:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
_test_input_queue.append(wire_name)
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
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
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
# 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.
@@ -238,7 +251,7 @@ func drain_outbound() -> Array[Dictionary]:
# 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:
static func action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
@@ -258,6 +271,10 @@ static func _action_enum_to_wire(action: int) -> String:
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -273,6 +290,11 @@ func _test_snapshot() -> Dictionary:
# 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)
@@ -285,7 +307,6 @@ func _test_snapshot() -> Dictionary:
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
@@ -316,6 +337,7 @@ func _test_snapshot() -> Dictionary:
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": _test_npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
@@ -394,6 +416,7 @@ func _test_snapshot() -> Dictionary:
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
@@ -0,0 +1,354 @@
extends RefCounted
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
## checklist YAML conditions and latches satisfied conditions.
##
## Usage:
## var evaluator := ChecklistEvaluator.new()
## evaluator.load_room("inventory_warehouse")
## evaluator.evaluate() # call each tick
## var results := evaluator.get_results()
##
## Condition types (per checklist.schema.json):
## player_near, player_facing, entity_present, entity_absent,
## expected_monologue, expected_dialogue, expected_interaction_verb
##
## Spec ref: D-030 (testability), checklist.schema.json (#497).
var _room_conditions: Array = [] # Conditions from per-room checklist
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
var _current_room_id: String = ""
var _content_base: String = "" # Absolute path to content/ directory
var _loaded: bool = false
func _init() -> void:
# Content directory lives at repo root (content/), one level above the Godot
# project (client/). In editor/dev mode we resolve via the project path.
# In exported builds, content is expected at res://content/ (copied by export
# preset) — the globalize fallback won't exist, so check res:// first.
if DirAccess.dir_exists_absolute("res://content"):
_content_base = ProjectSettings.globalize_path("res://content")
else:
var project_path := ProjectSettings.globalize_path("res://")
_content_base = project_path.path_join("../content")
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
func load_room(room_id: String) -> void:
if room_id == _current_room_id and _loaded:
return
_current_room_id = room_id
_room_conditions.clear()
# Clear per-room latches (keep cross-room latches)
var cross_ids := {}
for cond in _cross_conditions:
cross_ids[cond.get("id", "")] = true
var kept := {}
for cid in _latched:
if cross_ids.has(cid):
kept[cid] = true
_latched = kept
# Load per-room checklist
var room_path := _content_base.path_join(
"gauntlet/rooms/%s/checklist.yaml" % room_id)
var room_data := _load_checklist_file(room_path)
if room_data.has("conditions"):
_room_conditions = room_data["conditions"]
_warn_empty_ids(_room_conditions, room_path)
# Load cross-room checks (only on first load)
if _cross_conditions.is_empty():
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
var cross_data := _load_checklist_file(cross_path)
if cross_data.has("conditions"):
_cross_conditions = cross_data["conditions"]
_warn_empty_ids(_cross_conditions, cross_path)
_loaded = true
## Evaluate all conditions against current GameState. Latches newly met conditions.
func evaluate() -> void:
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty() or _latched.has(cid):
continue
if _evaluate_condition(cond):
_latched[cid] = true
## Returns array of {id, description, met} for all loaded conditions.
## Conditions with empty id are excluded (invalid, cannot be latched).
func get_results() -> Array:
var results: Array = []
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty():
continue
results.append({
"id": cid,
"description": cond.get("description", ""),
"condition_type": cond.get("condition_type", ""),
"met": _latched.has(cid),
})
return results
## Total number of loaded conditions (excludes conditions with empty id).
func get_total_count() -> int:
var count: int = 0
for cond in _room_conditions + _cross_conditions:
if not cond.get("id", "").is_empty():
count += 1
return count
## Number of latched (met) conditions.
func get_met_count() -> int:
return _latched.size()
## Whether all conditions are met.
func is_complete() -> bool:
return get_met_count() >= get_total_count() and get_total_count() > 0
## Whether any checklist is loaded.
func is_loaded() -> bool:
return _loaded
## Reset all state (room change to null, or disconnect).
func reset() -> void:
_room_conditions.clear()
_cross_conditions.clear()
_latched.clear()
_current_room_id = ""
_loaded = false
static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size():
if conditions[i].get("id", "").is_empty():
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
# -- Condition evaluation ------------------------------------------------------
func _evaluate_condition(cond: Dictionary) -> bool:
match cond.get("condition_type", ""):
"player_near":
return _eval_player_near(cond)
"player_facing":
return _eval_player_facing(cond)
"entity_present":
return _eval_entity_present(cond)
"entity_absent":
return _eval_entity_absent(cond)
"expected_monologue":
return _eval_expected_monologue(cond)
"expected_dialogue":
return _eval_expected_dialogue(cond)
"expected_interaction_verb":
return _eval_expected_interaction_verb(cond)
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
return false
## x/y and radius are in tile coordinates (matching GameState.player_position),
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
## happens only at render time.
func _eval_player_near(cond: Dictionary) -> bool:
var tx: float = float(cond.get("x", 0))
var ty: float = float(cond.get("y", 0))
var radius: float = float(cond.get("radius", 0.0))
var target := Vector2(tx, ty)
return GameState.player_position.distance_to(target) <= radius
func _eval_player_facing(cond: Dictionary) -> bool:
var direction: String = str(cond.get("direction", ""))
# Schema uses 4-cardinal (North/South/East/West).
# GameState uses 8-directional. Exact match only.
return GameState.player_facing == direction
func _eval_entity_present(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return _find_entity(entity_id)
func _eval_entity_absent(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return not _find_entity(entity_id)
func _eval_expected_monologue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_monologue == null:
return false
var text: String = str(GameState.current_monologue.get("text", ""))
return text.find(contains) >= 0
func _eval_expected_dialogue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_dialogue == null:
return false
var text: String = str(GameState.current_dialogue.get("speech", ""))
return text.find(contains) >= 0
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
var verb: String = str(cond.get("verb", ""))
for interaction in GameState.nearby_interactions:
if not interaction is Dictionary:
continue
if int(interaction.get("entity_id", -1)) != entity_id:
continue
var verbs: Array = interaction.get("verbs", [])
for v in verbs:
if not v is Dictionary:
continue
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
if v.get("available", true):
return true
return false
# -- Helpers -------------------------------------------------------------------
func _find_entity(entity_id: int) -> bool:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if int(entity.get("entity_id", -1)) == entity_id:
return true
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.
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("ChecklistEvaluator: cannot open %s" % path)
return {}
var text := file.get_as_text()
file.close()
return parse_checklist_yaml(text)
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
+18 -2
View File
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Npc": return ENTITY_COLOR_UNKNOWN
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
@@ -80,3 +89,10 @@ const PERIPHERAL_ALPHA: float = 0.5
# Facing direction indicator
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
+103 -2
View File
@@ -13,12 +13,16 @@ extends Node2D
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -60,8 +64,13 @@ func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
var old_pos := GameState.player_position
GameState.apply_snapshot(snapshot)
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
@@ -73,6 +82,16 @@ func _process(_delta: float) -> void:
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"):
@@ -98,6 +117,10 @@ func _process(_delta: float) -> void:
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()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -112,11 +135,19 @@ func _process(_delta: float) -> void:
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
# #501: Skip re-enable during teleport — _teleport_transition() disables
# smoothing for a clean camera snap. Defer by one frame to avoid the
# re-enable block in the same _process() call undoing the snap.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
if _teleport_in_progress:
_teleport_in_progress = false
else:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
@@ -146,6 +177,18 @@ func _process(_delta: float) -> void:
"verb": null,
}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
# then clears the accumulator for the next tick.
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
bug_report_dialog.record_tick(
GameState.current_tick,
JSON.stringify(GameState.current_snapshot),
_pending_record_inputs
)
_pending_record_inputs.clear()
# Consume-once per tick: show monologue text, then clear.
@@ -159,6 +202,10 @@ func _consume_monologue() -> void:
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
if mono_id.begins_with("room_reset"):
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
GameState.current_monologue = null
@@ -218,3 +265,57 @@ func _on_dialogue_dismissed() -> void:
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()
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Snap camera: disable smoothing, force re-anchor.
# _teleport_in_progress defers smoothing re-enable by one frame so the
# re-enable block at the bottom of _process() doesn't undo the snap.
camera.position_smoothing_enabled = false
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
_teleport_in_progress = true
# Clear client-side buffers
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black: instant black overlay, fades to transparent over 0.3s
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(0, 0, 0, 1.0)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
tween.tween_callback(_flash_rect.queue_free)
# #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:
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
tween.tween_callback(_flash_rect.queue_free)
@@ -11,6 +11,9 @@ enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
var current_state: State = State.DEFAULT
var hovered_entity_id: int = -1
var weapon_mode_active: bool = false
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
# Cursor shape transitions still fire — the character's body still orients to targets.
var insert_active: bool = true
signal state_changed(new_state: State)
signal hovered_entity_changed(entity_id: int)
@@ -305,8 +308,15 @@ func get_z_layer() -> int:
func should_show_interactions() -> bool:
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
if not insert_active:
return false
return not weapon_mode_active or _shift_held
func set_insert_active(active: bool) -> void:
insert_active = active
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles
+46 -17
View File
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
func _ready() -> void:
print("EntityRenderer: Initialized")
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
finished_ids.append(entity_id)
continue
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
_entity_tweens.erase(eid)
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
# D-033 color by entity kind (Phase 1 default)
# TODO(#361): derive from RelationshipState via knowledge graph
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
# Add facing indicator for the player entity
if entity_id == GameState.player_entity_id:
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
var visibility: Variant = entity_data.get("visibility")
@@ -119,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
# v2: Update facing indicator rotation (player entity only)
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
if indicator != null:
indicator.rotation = _facing_to_rotation(GameState.player_facing)
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
@@ -134,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
entity_node.queue_free()
entity_nodes.erase(entity_id)
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
@@ -155,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
parent_node.add_child(indicator)
# Convert facing direction string to rotation in radians (0 = North/up)
static func _facing_to_rotation(facing: String) -> float:
match facing:
"North": return 0.0
"Northeast": return PI / 4.0
"East": return PI / 2.0
"Southeast": return 3.0 * PI / 4.0
"South": return PI
"Southwest": return 5.0 * PI / 4.0
"West": return 3.0 * PI / 2.0
"Northwest": return 7.0 * PI / 4.0
_: return 0.0
+11 -7
View File
@@ -5,14 +5,15 @@ extends TileMapLayer
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
#
# Tile types (atlas coords in the programmatic source):
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (4,0) = reset_plate — amber (#502)
const TILE_SIZE: int = Constants.TILE_SIZE
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
# Wire-format string to TileType mapping
const TILE_TYPE_MAP: Dictionary = {
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
"wall": TileType.WALL,
"door": TileType.DOOR,
"object": TileType.OBJECT,
"reset_plate": TileType.RESET_PLATE,
}
var _initialized: bool = false
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
# Create an atlas source backed by a programmatic image
var source := TileSetAtlasSource.new()
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
# Floor (0,0) — dark gray
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
# Object (3,0) — teal
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
# Reset plate (4,0) — amber (#502)
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
var tex := ImageTexture.create_from_image(img)
source.texture = tex
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
# Create tile entries in the atlas
for i in range(4):
for i in range(5):
source.create_tile(Vector2i(i, 0))
var source_id := ts.add_source(source)
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.
+1 -1
View File
@@ -518,7 +518,7 @@ func test_bug_report_not_double_activatable() -> void:
func test_bug_report_action_not_on_wire() -> void:
# BUG_REPORT is client-only — must not produce a wire-format action name.
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.BUG_REPORT)
assert_that(wire_name).override_failure_message(
"BUG_REPORT must not produce a wire action name (client-only)"
).is_equal("")
+361
View File
@@ -0,0 +1,361 @@
## #507: WRONG button ring buffer tests.
## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd.
## Covers ring buffer capacity, circular overwrite, JSONL format compliance,
## replay compatibility, seed file, and regression against MVP behavior.
##
## MVP baseline (must still pass): snapshot.json, render.txt, description.txt.
## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt.
##
## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary)
## Sprint 11 client.md §Notes #507
class_name TestBugReportRingBuffer
extends GdUnitTestSuite
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
# Expected ring buffer capacity per spec.
const EXPECTED_CAPACITY := 60
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.current_tick = 0
GameState.room_id = null
GameState.player_stance = "Walk"
GameState.player_facing = "South"
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.rng_seed = null
# -- Helpers -------------------------------------------------------------------
func _make_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
auto_free(dialog)
add_child(dialog)
return dialog
## Create a minimal valid PlayerInput dict matching the replay.rs wire format.
## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict.
func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
return {"tick": tick, "action": action}
## Make a minimal snapshot JSON string for snapshot buffer testing.
func _make_snapshot_json(tick: int) -> String:
return JSON.stringify({
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
})
## Check a string is valid JSON array — mirrors replay.rs line expectation.
func _is_valid_json_array(line: String) -> bool:
if line.is_empty():
return false
var result = JSON.parse_string(line)
return result != null and result is Array
# -- Ring buffer capacity ------------------------------------------------------
func test_buffer_capacity_is_60() -> void:
# Spec: "Maintain a 60-entry circular buffer"
var dialog = _make_dialog()
if not dialog.has_method("_get_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_buffer_capacity()).override_failure_message(
"Ring buffer capacity must be 60 per spec"
).is_equal(EXPECTED_CAPACITY)
func test_snapshot_buffer_capacity_is_60() -> void:
# Parallel snapshot buffer must match input buffer capacity.
var dialog = _make_dialog()
if not dialog.has_method("_get_snapshot_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY)
# -- Pre-allocation -----------------------------------------------------------
func test_input_buffer_preallocated_at_ready() -> void:
# Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick."
var dialog = _make_dialog()
if not dialog.has_method("_get_input_buffer"):
push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507")
return
var buf = dialog._get_input_buffer()
assert_that(buf is Array).override_failure_message(
"Input buffer must be an Array"
).is_true()
assert_that(buf.size()).override_failure_message(
"Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY
).is_equal(EXPECTED_CAPACITY)
# -- Push and fill behavior ---------------------------------------------------
func test_push_fills_input_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs"):
push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507")
return
if not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"10 pushed ticks should produce filled count of 10"
).is_equal(10)
func test_push_fills_snapshot_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot"):
push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507")
return
if not dialog.has_method("_get_filled_snapshot_count"):
push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_snapshot(_make_snapshot_json(i))
assert_that(dialog._get_filled_snapshot_count()).is_equal(10)
# -- Circular overwrite -------------------------------------------------------
func test_circular_overwrite_evicts_oldest() -> void:
# After 61 pushes, filled count must be 60 (oldest evicted).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)"
).is_equal(EXPECTED_CAPACITY)
func test_circular_overwrite_keeps_newest_inputs() -> void:
# After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507")
return
# Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present.
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")])
var jsonl: String = dialog._format_inputs_jsonl()
# Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten.
# We check that the output has exactly 60 lines.
var lines: PackedStringArray = jsonl.split("\n", false)
# Filter out blank lines (PackedStringArray has no filter — convert to Array first)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"After 61 pushes, JSONL output must have exactly 60 non-blank lines"
).is_equal(EXPECTED_CAPACITY)
func test_exact_60_pushes_no_eviction() -> void:
# After exactly 60 pushes, all 60 are present.
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After exactly 60 pushes, all 60 entries should be present"
).is_equal(EXPECTED_CAPACITY)
# -- JSONL format compliance (replay.rs contract) ----------------------------
func test_inputs_jsonl_each_line_is_json_array() -> void:
# replay.rs: "Each line is a JSON array of PlayerInput for one tick."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_inputs(i, [_make_input(i)])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
for line in non_blank:
assert_that(_is_valid_json_array(line)).override_failure_message(
"Each JSONL line must be a valid JSON array, got: %s" % line
).is_true()
func test_inputs_jsonl_idle_tick_is_empty_array() -> void:
# replay.rs: "Empty array = idle tick (no input sent)."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, []) # Idle tick: no inputs
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"One idle tick should produce exactly 1 JSONL line"
).is_equal(1)
assert_that(non_blank[0].strip_edges()).override_failure_message(
"Idle tick line must be an empty JSON array '[]'"
).is_equal("[]")
func test_inputs_jsonl_has_tick_field() -> void:
# replay.rs PlayerInput expects {"tick": N, "action": "..."}
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")])
var jsonl: String = dialog._format_inputs_jsonl()
# Parse the first line and verify structure
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_greater(0)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size() > 0).is_true()
var first_input: Dictionary = arr[0]
assert_that(first_input.has("tick")).override_failure_message(
"Each PlayerInput in JSONL must have 'tick' field"
).is_true()
assert_that(first_input.has("action")).override_failure_message(
"Each PlayerInput in JSONL must have 'action' field"
).is_true()
func test_inputs_jsonl_multiple_actions_per_tick() -> void:
# replay.rs supports multiple PlayerInput per tick (one array entry per action).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, [
_make_input(0, "MoveNorth"),
_make_input(0, "Pause"),
])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(1)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size()).override_failure_message(
"Tick with 2 actions must have 2 entries in the JSONL array"
).is_equal(2)
func test_snapshots_jsonl_each_line_is_valid_json() -> void:
# Each snapshot line should be valid JSON (parsed snapshot).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"):
push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_snapshot(_make_snapshot_json(i))
var jsonl: String = dialog._format_snapshots_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(3)
for line in non_blank:
var parsed = JSON.parse_string(line)
assert_that(parsed != null).override_failure_message(
"Each snapshot line must be valid JSON, got: %s" % line
).is_true()
assert_that(parsed is Dictionary).override_failure_message(
"Each snapshot line must parse to a Dictionary"
).is_true()
# -- Seed file ----------------------------------------------------------------
func test_seed_written_on_capture() -> void:
# Spec: "Include the server's current RNG seed... Output as seed.txt"
# We can't easily verify file output in headless mode, but we verify
# the dialog has the method to produce the seed value.
var dialog = _make_dialog()
if not dialog.has_method("_get_current_seed"):
push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507")
return
# Should return some Variant (int or String) without crashing
var seed_val = dialog._get_current_seed()
assert_that(seed_val != null).override_failure_message(
"_get_current_seed() must not return null (use 0 or 'unavailable' if seed unavailable)"
).is_true()
# -- Room metadata regression -------------------------------------------------
func test_description_txt_still_contains_room_id() -> void:
# Regression: MVP description.txt must still include room metadata.
GameState.current_tick = 5
GameState.room_id = "warehouse_01"
GameState.player_stance = "Walk"
GameState.player_facing = "North"
GameState.player_position = Vector2(10.0, 10.0)
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
# Call the render text method to verify room shows up in render output
# (description.txt itself writes via FileAccess, difficult in headless — test the render path)
if dialog.has_method("_render_snapshot_text"):
# The text render is for render.txt, but description.txt room comes from GameState.room_id
# We indirectly verify by checking that the game state has the right fields.
assert_that(GameState.room_id).is_equal("warehouse_01")
GameState.room_id = null
# -- MVP regression -----------------------------------------------------------
func test_render_snapshot_text_still_works() -> void:
# Regression: MVP _render_snapshot_text() must still work after upgrade.
GameState.current_tick = 10
GameState.player_position = Vector2(5.0, 5.0)
GameState.player_facing = "East"
GameState.player_stance = "Careful"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
if not dialog.has_method("_render_snapshot_text"):
push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk")
return
var text: String = dialog._render_snapshot_text()
assert_that(text.length() > 0).is_true()
assert_that(text.contains("t10")).is_true()
func test_dialog_is_active_api_unchanged() -> void:
# Regression: public API from MVP must be unchanged.
var dialog = _make_dialog()
assert_that(dialog.has_method("start_capture")).override_failure_message(
"start_capture() must still be present (MVP regression)"
).is_true()
assert_that(dialog.has_method("is_active")).override_failure_message(
"is_active() must still be present (MVP regression)"
).is_true()
+692
View File
@@ -0,0 +1,692 @@
## #503: Auto-checklist progress tracking — unit + integration tests.
##
## Tests cover:
## 1. YAML parser: basic types, conditions array, edge cases
## 2. Condition evaluation: all 7 condition types
## 3. Latching: conditions stay met once satisfied
## 4. Room change: per-room conditions reset, cross-room conditions persist
## 5. Overlay: visibility gating on gauntlet_mode
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
##
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
class_name TestChecklist
extends GdUnitTestSuite
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd")
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.nearby_interactions = []
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.player_inventory = []
# -- YAML Parser Tests ---------------------------------------------------------
func test_parse_empty_yaml() -> void:
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
assert_that(result.size()).is_equal(0)
func test_parse_top_level_string() -> void:
var yaml := "room_id: inventory_warehouse"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
func test_parse_top_level_quoted_string() -> void:
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
func test_parse_single_condition() -> void:
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.has("conditions")).is_true()
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["id"]).is_equal("test-1")
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
assert_that(conditions[0]["x"]).is_equal(10)
assert_that(conditions[0]["y"]).is_equal(20)
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
func test_parse_multiple_conditions() -> void:
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(2)
assert_that(conditions[0]["id"]).is_equal("cond-a")
assert_that(conditions[1]["id"]).is_equal("cond-b")
assert_that(conditions[1]["direction"]).is_equal("East")
func test_parse_comments_ignored() -> void:
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["entity_id"]).is_equal(5)
func test_parse_integer_and_float_values() -> void:
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var cond: Dictionary = result["conditions"][0]
assert_that(cond["x"]).is_equal(42)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
func test_parse_scope_field() -> void:
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("scope")).is_equal("cross_room")
func test_parse_inline_comment_stripped() -> void:
var yaml := "room_id: test # this is a comment"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
# -- Condition Evaluation Tests ------------------------------------------------
func _make_evaluator(conditions: Array):
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = conditions
evaluator._loaded = true
return evaluator
func test_eval_player_near_within_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-1", "condition_type": "player_near",
"x": 10, "y": 21, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
).is_equal(1)
func test_eval_player_near_outside_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-2", "condition_type": "player_near",
"x": 10, "y": 30, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
).is_equal(0)
func test_eval_player_near_exact_boundary() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-3", "condition_type": "player_near",
"x": 10, "y": 22, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: distance exactly equals radius — should be met (<=)"
).is_equal(1)
func test_eval_player_facing_match() -> void:
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "face-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_player_facing_no_match() -> void:
GameState.player_facing = "North"
var evaluator = _make_evaluator([{
"id": "face-2", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_player_facing_diagonal_no_match() -> void:
# 8-directional facing "Northeast" should NOT match "East" or "North"
GameState.player_facing = "Northeast"
var evaluator = _make_evaluator([{
"id": "face-diag", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_facing: Northeast should NOT match East (exact match only)"
).is_equal(0)
func test_eval_entity_present_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
]
var evaluator = _make_evaluator([{
"id": "present-1", "condition_type": "entity_present",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_entity_present_not_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "present-2", "condition_type": "entity_present",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_entity_absent_when_not_visible() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-1", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 99 not in visible_entities — should be met"
).is_equal(1)
func test_eval_entity_absent_when_visible() -> void:
GameState.visible_entities = [
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-2", "condition_type": "entity_absent",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 10 IS visible — should NOT be met"
).is_equal(0)
func test_eval_expected_monologue_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-1", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_monologue_no_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-2", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_monologue_null() -> void:
GameState.current_monologue = null
var evaluator = _make_evaluator([{
"id": "mono-3", "condition_type": "expected_monologue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_monologue: null monologue should not match"
).is_equal(0)
func test_eval_expected_dialogue_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-1", "condition_type": "expected_dialogue",
"contains": "Who are you",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_dialogue_no_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-2", "condition_type": "expected_dialogue",
"contains": "Goodbye",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_dialogue_null() -> void:
GameState.current_dialogue = null
var evaluator = _make_evaluator([{
"id": "dlg-3", "condition_type": "expected_dialogue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_dialogue: null dialogue should not match"
).is_equal(0)
func test_eval_interaction_verb_match() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-1", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_interaction_verb_by_kind() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-kind", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Take",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: should match by kind='Take' as well as label"
).is_equal(1)
func test_eval_interaction_verb_wrong_entity() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
}]
var evaluator = _make_evaluator([{
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
"entity_id": 99, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: wrong entity_id should not match"
).is_equal(0)
func test_eval_interaction_verb_unavailable() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
}]
var evaluator = _make_evaluator([{
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: unavailable verb should not match"
).is_equal(0)
func test_eval_interaction_verb_no_interactions() -> void:
GameState.nearby_interactions = []
var evaluator = _make_evaluator([{
"id": "verb-none", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# -- Latching Tests ------------------------------------------------------------
func test_latching_condition_stays_met() -> void:
# Condition met on first evaluate, stays met even when state changes.
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "latch-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Change state so condition would be false if re-evaluated fresh
GameState.player_facing = "North"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Latched condition should stay met even after state changes"
).is_equal(1)
func test_latching_monologue_transient() -> void:
# Monologue appears for one tick, then disappears. Condition should latch.
var evaluator = _make_evaluator([{
"id": "mono-latch", "condition_type": "expected_monologue",
"contains": "recalibrated",
}])
# Tick 1: no monologue
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# Tick 2: monologue fires
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Tick 3: monologue consumed (null)
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Monologue condition should stay latched after monologue disappears"
).is_equal(1)
func test_multiple_conditions_partial_latching() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
])
# Tick 1: only facing matches
GameState.player_facing = "East"
GameState.visible_entities = []
GameState.player_position = Vector2(0, 0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(3)
# Tick 2: entity also visible
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
# Tick 3: player moves to target
GameState.player_position = Vector2(50.0, 50.0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(3)
assert_that(evaluator.is_complete()).is_true()
# -- Room Change Tests ---------------------------------------------------------
func test_room_change_resets_per_room_conditions() -> void:
var evaluator = ChecklistEvaluatorScript.new()
# Manually set conditions to avoid file loading
evaluator._room_conditions = [
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Simulate room change by loading a new "room"
evaluator._current_room_id = "old_room"
evaluator._room_conditions = [
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
]
# Clear latches for the new room (simulating load_room behavior)
evaluator._latched.clear()
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"After room change, old latches should be cleared; new condition not met"
).is_equal(0)
func test_reset_clears_all_state() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
])
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
evaluator.reset()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.get_total_count()).is_equal(0)
# -- get_results Tests ---------------------------------------------------------
func test_get_results_structure() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results.size()).is_equal(1)
assert_that(results[0]["id"]).is_equal("c1")
assert_that(results[0]["description"]).is_equal("Test condition")
assert_that(results[0]["condition_type"]).is_equal("player_facing")
assert_that(results[0]["met"]).is_true()
func test_get_results_unmet() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results[0]["met"]).is_false()
# -- Overlay Visibility Tests --------------------------------------------------
func _make_checklist_overlay() -> Control:
var overlay = Control.new()
overlay.set_script(ChecklistOverlayScript)
auto_free(overlay)
add_child(overlay)
return overlay
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be hidden in non-gauntlet mode"
).is_false()
func test_overlay_visible_in_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be visible in gauntlet mode"
).is_true()
func test_overlay_hides_on_gauntlet_deactivation() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).is_true()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Overlay must hide when gauntlet mode deactivates"
).is_false()
func test_overlay_evaluator_accessible() -> void:
var overlay := _make_checklist_overlay()
var evaluator = overlay.get_evaluator()
assert_that(evaluator).override_failure_message(
"Overlay should expose evaluator via get_evaluator()"
).is_not_null()
func test_overlay_in_main_scene() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
var instance: Node = scene.instantiate()
auto_free(instance)
add_child(instance)
instance._process(0.016)
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
assert_that(overlay).override_failure_message(
"ChecklistOverlay node should exist in main scene tree"
).is_not_null()
if overlay is CanvasItem:
assert_that((overlay as CanvasItem).visible).override_failure_message(
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
).is_false()
# -- Integration: Snapshot -> Evaluation ----------------------------------------
func test_integration_snapshot_to_evaluator() -> void:
# Integration test: GameState snapshot data -> evaluator -> correct results.
# Tests the evaluator directly (overlay wiring tested separately).
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = [
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
# Simulate gauntlet snapshot with player entity
GameState.visible_entities = [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
]
GameState.player_facing = "North"
evaluator.evaluate()
# Entity present should be met, facing should not
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(2)
# Change facing — second condition should also latch
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
assert_that(evaluator.is_complete()).is_true()
# Verify results array contains both conditions as met
var results: Array = evaluator.get_results()
for r in results:
assert_that(r["met"]).override_failure_message(
"Condition '%s' should be met after snapshot sequence" % r["id"]
).is_true()
# -- Edge Cases ----------------------------------------------------------------
func test_empty_entity_list_entity_present() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-empty", "condition_type": "entity_present",
"entity_id": 0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_empty_entity_list_entity_absent() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-absent-empty", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent with empty visible_entities should be met"
).is_equal(1)
func test_zero_radius_player_near() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "edge-zero-radius", "condition_type": "player_near",
"x": 10, "y": 20, "radius": 0.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near with radius 0 at exact position should be met"
).is_equal(1)
func test_unknown_condition_type() -> void:
var evaluator = _make_evaluator([{
"id": "edge-unknown", "condition_type": "nonexistent_type",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Unknown condition type should not be met"
).is_equal(0)
func test_no_conditions_loaded() -> void:
var evaluator = ChecklistEvaluatorScript.new()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_total_count()).is_equal(0)
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.is_complete()).is_false()
# -- Helper: recursive node search --------------------------------------------
func _find_node_recursive(root: Node, target_name: String) -> Node:
if root.name == target_name:
return root
for child in root.get_children():
var found := _find_node_recursive(child, target_name)
if found != null:
return found
return null
+14 -12
View File
@@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void:
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_player_facing() -> void:
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
# Indicator rotation = facing_angle + PI/2 (0=North basis).
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
# Test each cardinal + diagonal direction
var expected := {
"North": 0.0,
"East": PI / 2.0,
"South": PI,
"West": 3.0 * PI / 2.0,
# {facing_angle → expected indicator rotation}
var angles := {
-PI / 2.0: 0.0, # North
0.0: PI / 2.0, # East
PI / 2.0: PI, # South
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
}
for dir in expected:
GameState.player_facing = dir
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(entity)
assert_that(indicator.rotation).override_failure_message(
"%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation]
).is_equal_approx(expected[dir], 0.001)
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
+366
View File
@@ -0,0 +1,366 @@
## #521: Confrontation D-033 color shift — QA test suite
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
## Sprint Completion Proof (joint.md):
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
## - Cursor hover tint also updates to match relationship
## - Palette matches D-033 exactly
##
## Tests are structured in layers:
## 1. D-033 color palette constants — always pass (no implementation dependency)
## 2. Relationship-to-color mapping — tests the lookup function
## 3. Entity renderer relationship coloring — tests that entities USE relationship
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
## 5. Protocol/GameState passthrough — tests data pipeline integrity
class_name TestColorShift
extends GdUnitTestSuite
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
# -- Test data -----------------------------------------------------------------
# Entity with relationship field (v4 protocol format)
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
return {
"entity_id": entity_id,
"x": x, "y": y, "z": 0,
"kind": {"variant": kind, "data": null},
"visibility": "Forward",
"relationship": relationship,
"observation": "Visible",
}
func _make_entity_renderer() -> Node2D:
var renderer: Node2D = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func before_test() -> void:
GameState.player_entity_id = 1
# ==============================================================================
# Layer 1: D-033 Color Palette Constants
# These tests verify the palette is defined correctly. No implementation needed.
# ==============================================================================
func test_d033_unknown_teal() -> void:
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
func test_d033_friendly_green() -> void:
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
func test_d033_poi_amber() -> void:
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d033_hostile_red() -> void:
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d033_object_grey() -> void:
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
func test_d033_player_cool_white() -> void:
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
func test_d033_palette_all_distinct() -> void:
# All 6 D-033 colors must be distinct from each other
var colors: Array[Color] = [
Constants.ENTITY_COLOR_UNKNOWN,
Constants.ENTITY_COLOR_FRIENDLY,
Constants.ENTITY_COLOR_POI,
Constants.ENTITY_COLOR_HOSTILE,
Constants.ENTITY_COLOR_OBJECT,
Constants.ENTITY_COLOR_PLAYER,
]
for i in range(colors.size()):
for j in range(i + 1, colors.size()):
assert_that(colors[i] != colors[j]).is_true()
# ==============================================================================
# Layer 2: Relationship-to-Color Mapping
# Tests the lookup function that maps relationship strings to D-033 colors.
# Depends on #521 adding color_for_relationship() to Constants.
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
# ==============================================================================
func _has_color_for_relationship() -> bool:
# Check if the Constants script has a color_for_relationship method.
for method in ConstantsScript.get_script_method_list():
if method.name == "color_for_relationship":
return true
return false
func test_relationship_color_unknown() -> void:
if not _has_color_for_relationship():
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
return
var color: Color = Constants.color_for_relationship("Unknown")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_color_friendly() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Friendly")
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_color_person_of_interest() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("PersonOfInterest")
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_color_hostile() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Hostile")
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_color_fallback() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("SomethingWeird")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# ==============================================================================
# Layer 3: Entity Renderer — Relationship-Based Coloring
# Tests that entity_renderer uses the relationship field for NPC colors.
# Player and Object entities should remain unaffected by relationship field.
# ==============================================================================
func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
# Object entities always use ENTITY_COLOR_OBJECT
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
# ==============================================================================
# Layer 4: Tween on Relationship Change
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
# D-063: "entity D-033 color may fade" on confrontation delivery.
# ==============================================================================
func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
func test_color_shift_reaches_target() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Simulate time passing: ~1.5 seconds of frames
var elapsed: float = 0.0
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
# The second change should preempt the first tween and converge to Hostile.
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
# First change: Unknown → Friendly
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
# Advance partway (0.1s of a 0.5s tween)
for i in range(6):
renderer._process(1.0 / 60.0)
# Second change mid-tween: Friendly → Hostile (preempts first)
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Advance past full duration
var elapsed: float = 0.0
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_same_relationship_no_tween() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
renderer.update_entities(entities)
var color_second: Color = node.color
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
# ==============================================================================
# Layer 5: Protocol — Relationship Field Round-Trip
# ==============================================================================
func test_protocol_entity_relationship_decoded() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": "PersonOfInterest",
"visibility": "Forward",
"observation": "Visible",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("PersonOfInterest")
func test_protocol_entity_relationship_defaults_unknown() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("Unknown")
func test_protocol_entity_all_relationship_values() -> void:
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
for rel in relationships:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": rel,
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal(rel)
# ==============================================================================
# Layer 6: GameState — Relationship Data Passthrough
# ==============================================================================
func test_game_state_preserves_relationship() -> void:
var snapshot: Dictionary = {
"tick": 1,
"entities": [_make_entity(2, "Npc", "Friendly")],
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.visible_entities.size()).is_equal(1)
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
func test_game_state_relationship_changes_between_snapshots() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
# ==============================================================================
# Helpers
# ==============================================================================
func _entity_uses_relationship(renderer: Node2D) -> bool:
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
func _renderer_has_tween_support(renderer: Node2D) -> bool:
return renderer.get("_entity_tweens") != null
+40
View File
@@ -305,6 +305,46 @@ func test_cursor_changes_require_los() -> void:
cursor.queue_free()
# -- OQ-07: Insert-off behavior (D-056 amendment, #522) ----------------------
# Option (a): cursor shape still changes, verb labels suppressed.
func test_insert_off_cursor_still_changes_shape() -> void:
# OQ-07 option (a): cursor state machine still fires when insert is off.
# The character's body orients toward targets even without insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("set_hover_target"):
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_insert_off_suppresses_interactions() -> void:
# OQ-07 option (a): with insert off, should_show_interactions() returns false.
# Verb labels (z-layer 6) are suppressed — no actionable insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_insert_on_restores_interaction_display() -> void:
# Re-enabling insert allows interactions to show again.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
+288
View File
@@ -0,0 +1,288 @@
## #501: Hub teleport client UX — QA test suite
## Spec refs: D-020 (protocol), D-030 (testability)
## Sprint Completion Proof (joint.md):
## - Home key sends TeleportToHub in Gauntlet mode
## - 0.3s fade-to-black-and-back plays on teleport
## - Dialogue/monologue/interaction buffer cleared on teleport
## - Non-Gauntlet: action rejected, client shows no effect
class_name TestHubTeleport
extends GdUnitTestSuite
# -- Fixtures ------------------------------------------------------------------
var _gauntlet_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
"gauntlet_mode": true,
"room_id": "proof_room",
}
var _normal_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
}
func before_test() -> void:
GameState.gauntlet_mode = false
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.room_id = null
InputMapper.input_queue.clear()
SimBridge.reset_test_state()
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
func test_teleport_hub_action_exists() -> void:
# Verify the enum value exists and is distinct
var action: int = InputMapper.Action.TELEPORT_HUB
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
# -- InputMapper: Gauntlet mode guard -----------------------------------------
func test_teleport_hub_blocked_outside_gauntlet() -> void:
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
GameState.gauntlet_mode = false
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_false()
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
GameState.gauntlet_mode = true
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_true()
# -- GameState: gauntlet_mode from snapshot ------------------------------------
func test_gauntlet_mode_set_from_snapshot() -> void:
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("proof_room")
func test_gauntlet_mode_false_when_absent() -> void:
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
assert_that(GameState.room_id).is_null()
func test_gauntlet_mode_transitions_off() -> void:
# Gauntlet on → off: mode should clear
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
# -- SimBridge: wire format encoding -------------------------------------------
func test_teleport_hub_wire_name() -> void:
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name).is_equal("TeleportToHub")
func test_teleport_hub_wire_not_empty() -> void:
# Wire name must not be empty (empty = client-only, not sent to server)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
func test_teleport_hub_encode_roundtrip() -> void:
# Verify MessagePack encode→decode roundtrip for TeleportToHub
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(42)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
assert_that(decoded.action.data).is_null()
# -- SimBridge: test mode teleport behavior ------------------------------------
func test_test_mode_teleport_resets_position() -> void:
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
SimBridge.reset_test_state()
# Move player away first
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Player should be back at hub spawn
var player_entity: Dictionary = snap.entities[0]
assert_that(player_entity.x).is_equal(10.0)
assert_that(player_entity.y).is_equal(10.0)
func test_test_mode_teleport_clears_dialogue() -> void:
# TeleportToHub in test mode should clear dialogue state
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
SimBridge._test_snapshot()
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Teleport detection -------------------------------------------------------
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
# These tests verify the distance math against that threshold.
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
func test_detect_teleport_large_jump() -> void:
# Position jump > threshold should be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(50.0, 50.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
func test_detect_teleport_normal_movement() -> void:
# Normal 1-tile movement should NOT be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_diagonal_movement() -> void:
# Diagonal movement (1,1) — distance ~1.41, not a teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 11.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_exactly_threshold() -> void:
# Exactly threshold — should NOT trigger (> not >=)
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_just_over() -> void:
# Just over threshold — should trigger
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.1, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
# -- Buffer clearing on teleport -----------------------------------------------
func test_teleport_clears_dialogue_in_test_mode() -> void:
# TeleportToHub in test mode should clear dialogue state via the pipeline
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Dialogue should be cleared by teleport
assert_that(SimBridge._test_in_dialogue).is_false()
assert_that(snap.current_dialogue).is_null()
func test_teleport_resets_position_in_test_mode() -> void:
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
var player: Dictionary = snap.entities[0]
assert_that(player.x).is_equal(10.0)
assert_that(player.y).is_equal(10.0)
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Send input integration (test mode) ----------------------------------------
func test_send_teleport_hub_in_test_mode() -> void:
# Verify send_input accepts TELEPORT_HUB in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
var err := SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(err).is_equal(OK)
func test_send_teleport_hub_queues_wire_action() -> void:
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
func test_gauntlet_mode_from_test_snapshot() -> void:
# Verify test snapshot includes gauntlet_mode field
SimBridge.reset_test_state()
SimBridge._test_gauntlet_mode = true
var snap: Dictionary = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_true()
SimBridge._test_gauntlet_mode = false
snap = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_false()
# -- Live mode outbound encoding -----------------------------------------------
func test_teleport_hub_outbound_entry() -> void:
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
# (We can't test full live mode in unit tests, but we test the encode path)
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
# Decode and verify
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
+119
View File
@@ -0,0 +1,119 @@
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
##
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
class_name TestInputMapperFacing
extends GdUnitTestSuite
# -- _angle_to_octant ----------------------------------------------------------
func test_angle_to_octant_east() -> void:
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
func test_angle_to_octant_north() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
func test_angle_to_octant_south() -> void:
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
func test_angle_to_octant_west() -> void:
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
func test_angle_to_octant_northeast() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
func test_angle_to_octant_southeast() -> void:
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
func test_angle_to_octant_southwest() -> void:
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
func test_angle_to_octant_northwest() -> void:
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
# -- _snap_to_octant_dir -------------------------------------------------------
func test_snap_east() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
func test_snap_north() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
func test_snap_south() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
func test_snap_west() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
func test_snap_northeast() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
func test_snap_southwest() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
func test_snap_zero_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
func test_snap_tiny_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
func test_snap_diagonal_bias() -> void:
# Slightly more east than north — should snap to northeast
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
# -- _wasd_to_world_dir --------------------------------------------------------
func test_wasd_forward_facing_east() -> void:
# W pressed, facing east → move east
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_forward_facing_north() -> void:
# W pressed, facing north → move north
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, -1))
func test_wasd_backward_facing_north() -> void:
# S pressed, facing north → move south
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_strafe_right_facing_north() -> void:
# D pressed, facing north → move east
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_strafe_left_facing_north() -> void:
# A pressed, facing north → move west
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
assert_object(result).is_equal(Vector2i(-1, 0))
func test_wasd_forward_facing_south() -> void:
# W pressed, facing south → move south
InputMapper.facing_angle = PI / 2.0 # South
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_diagonal_forward_right_facing_east() -> void:
# W+D pressed, facing east → move southeast
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
assert_object(result).is_equal(Vector2i(1, 1))
func test_wasd_strafe_right_facing_west() -> void:
# D pressed, facing west → move north
InputMapper.facing_angle = PI # West
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(0, -1))
func after_test() -> void:
InputMapper.reset_facing_state()
+400
View File
@@ -0,0 +1,400 @@
## #522: OQ-07 resolution — no-insert interaction behavior.
## Tests that when insert_active == false, interaction labels disappear
## (diegetic test from D-056 and D-057), and that cursor and interaction
## list agree on insert state.
##
## D-056: "Interaction labels render on z-layer 6 (insert overlay).
## If the insert is off, labels disappear."
## D-057: "Labels render on z-layer 6. If insert is off, labels disappear."
##
## OQ-07 resolution (expected option a): cursor shape still changes
## (character physically orients), but verb labels are suppressed.
##
## Spec refs: D-056, D-057, D-048, D-049
## Ticket #522
class_name TestInsertOffBehavior
extends GdUnitTestSuite
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
GameState.insert_active = true
# -- Helpers -------------------------------------------------------------------
func _make_cursor() -> Node:
for path in ["res://ui/cursor_state_machine.tscn", "res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
return null
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
push_warning("TestInsertOffBehavior: cursor scene not found — test skipped (awaiting #522)")
return cursor
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
add_child(node)
return node
return null
func _make_list_or_skip() -> Node:
var list = _make_interaction_list()
if list == null:
push_warning("TestInsertOffBehavior: interaction list scene not found — test skipped (awaiting #522)")
return list
func _set_test_interactions() -> void:
GameState.nearby_interactions = [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
func _clear_test_interactions() -> void:
GameState.nearby_interactions = []
# -- Interaction list: diegetic test (D-056, D-057) ---------------------------
func test_insert_off_hides_interaction_list() -> void:
# D-056/D-057 diegetic test: "If the insert is off, labels disappear."
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"D-056/D-057 diegetic test: interaction list must be hidden when insert is off"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).override_failure_message(
"Interaction list must show 0 verbs when insert is off"
).is_equal(0)
list.queue_free()
_clear_test_interactions()
func test_insert_on_shows_interaction_list() -> void:
# When insert is on (normal state), verbs should be visible.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Interaction list must be visible when insert is on and verbs present"
).is_true()
list.queue_free()
_clear_test_interactions()
func test_insert_reenable_restores_list() -> void:
# Toggling insert off then on restores verb list visibility.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if not list.has_method("set_insert_active"):
list.queue_free()
_clear_test_interactions()
return
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Re-enabling insert must restore list visibility"
).is_true()
list.queue_free()
_clear_test_interactions()
# -- Cursor: insert-off behavior (OQ-07 resolution option a) ------------------
func test_insert_off_suppresses_should_show_interactions() -> void:
# OQ-07 option (a): labels are suppressed when insert is off.
# Regardless of cursor visual state, should_show_interactions() must return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return false when insert is off"
).is_false()
cursor.queue_free()
func test_insert_on_restores_should_show_interactions() -> void:
# After re-enabling insert, interactions should show again (not in weapon mode).
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_insert_active(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return true after re-enabling insert"
).is_true()
cursor.queue_free()
func test_insert_off_at_startup_no_corruption() -> void:
# Insert can be off from the start — no state machine corruption.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("get_state"):
cursor.queue_free()
return
cursor.set_insert_active(false)
# Cursor must be in a valid state (not null/error)
var state_str := str(cursor.get_state())
assert_that(["Default", "EntityHover", "ObjectHover", "WeaponAim"].has(state_str)).override_failure_message(
"Cursor must be in a valid state after insert-off at startup, got: %s" % state_str
).is_true()
cursor.queue_free()
# -- OQ-07 option (a): cursor shape still changes, labels suppressed ----------
func test_insert_off_option_a_cursor_still_transitions() -> void:
# Option (a): cursor shape changes even when insert off
# (character physically orients to target, just no labels).
# If implementation chose option (b) instead, this test would fail — that's informative.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_hover_target"):
push_warning("TestInsertOffBehavior: set_insert_active or set_hover_target not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": true})
# Option (a): cursor SHOULD be in EntityHover despite insert being off
# Option (b): cursor would stay in Default
# Test documents expected behavior — fail message explains which option is active
if cursor.has_method("get_state"):
var state := str(cursor.get_state())
# If this assertion fails, option (b) was implemented (cursor full-suppressed).
# Neither (a) nor (b) is wrong — this test documents which was chosen.
# Consult decisions/perception.md for the OQ-07 amendment.
assert_that(state).override_failure_message(
"OQ-07 option (a): cursor should still transition to EntityHover when insert is off. "
+ "If this fails, option (b) was implemented (full suppression) — update this test to assert 'Default' instead."
).is_equal("EntityHover")
cursor.queue_free()
# -- Edge cases ---------------------------------------------------------------
func test_insert_off_plus_weapon_mode_stays_suppressed() -> void:
# Insert-off AND weapon mode: should_show_interactions() must still return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_weapon_mode"):
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off + weapon mode must keep should_show_interactions() false"
).is_false()
cursor.queue_free()
func test_insert_off_shift_override_still_suppressed() -> void:
# OQ-07: Insert-off trumps Shift override.
# Shift restores interactions in weapon mode, but insert-off is a harder constraint.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_shift_held"):
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off must suppress interactions even with Shift held (insert-off > shift override)"
).is_false()
cursor.queue_free()
func test_insert_off_rapid_toggle_no_corruption() -> void:
# Rapid on/off toggle must not leave either system in inconsistent state.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
cursor.queue_free()
return
for _i in range(10):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
# After 10 toggles, insert should be on
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"After rapid toggle ending on 'on', interactions must be visible"
).is_true()
cursor.queue_free()
# -- Cross-system consistency (cursor + list agree) ---------------------------
func test_cursor_and_list_agree_on_insert_off() -> void:
# Both cursor and interaction list must be suppressed when insert is off.
# This tests the integration contract — both must read from the same source of truth.
var cursor = _make_cursor_or_skip()
var list = _make_list_or_skip()
if cursor == null or list == null:
if cursor != null: cursor.queue_free()
if list != null: list.queue_free()
return
_set_test_interactions()
if cursor.has_method("set_insert_active") and list.has_method("set_insert_active"):
cursor.set_insert_active(false)
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
var cursor_suppressed := true
if cursor.has_method("should_show_interactions"):
cursor_suppressed = not cursor.should_show_interactions()
var list_suppressed := true
if list.has_method("is_showing"):
list_suppressed = not list.is_showing()
elif list.has_method("get_visible_verb_count"):
list_suppressed = list.get_visible_verb_count() == 0
assert_that(cursor_suppressed).override_failure_message(
"Cursor: should_show_interactions() must return false when insert is off"
).is_true()
assert_that(list_suppressed).override_failure_message(
"Interaction list: must be hidden when insert is off"
).is_true()
cursor.queue_free()
list.queue_free()
_clear_test_interactions()
func test_game_state_insert_active_field_exists() -> void:
# GameState must have an insert_active field after #522 implementation.
assert_that(GameState.get("insert_active") != null or "insert_active" in GameState).override_failure_message(
"GameState must have insert_active field after #522 — awaiting implementation"
).is_true()
func test_game_state_insert_active_default_true() -> void:
# Default state: insert is on (player starts with a functioning neural insert).
if not ("insert_active" in GameState):
push_warning("TestInsertOffBehavior: GameState.insert_active not found — awaiting #522")
return
assert_that(GameState.insert_active).override_failure_message(
"GameState.insert_active should default to true (insert is normally on)"
).is_true()
# -- D-049: z-layer verification (labels on layer 6) -------------------------
func test_interaction_list_on_insert_layer() -> void:
# D-056/D-057: "Labels render on z-layer 6 (insert overlay)"
# This is already tested in test_interaction_list.gd but we verify here
# that the z-layer is CANVAS_INSERT (the insert overlay layer).
var list = _make_list_or_skip()
if list == null:
return
if list.has_method("get_z_layer"):
assert_that(list.get_z_layer()).override_failure_message(
"Interaction list labels must render on CANVAS_INSERT (z-layer 6 per D-049)"
).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -- Regression: existing behavior unchanged ----------------------------------
func test_sprint_suppression_still_works_with_insert_on() -> void:
# D-055 regression: Sprint suppresses interaction list regardless of insert state.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
# Ensure insert is on (should not affect sprint suppression)
if list.has_method("set_insert_active"):
list.set_insert_active(true)
GameState.player_stance = "Sprint"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Sprint must suppress interaction list even when insert is on (D-055 regression)"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
_clear_test_interactions()
GameState.player_stance = "Walk"
func test_weapon_mode_suppression_still_works_with_insert_on() -> void:
# D-056 regression: weapon mode suppresses interactions when insert is on.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active"):
cursor.set_insert_active(true) # Insert is on
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Weapon mode must suppress interactions when insert is on (D-056 regression)"
).is_false()
cursor.queue_free()
+3 -3
View File
@@ -268,12 +268,12 @@ func test_insert_off_hides_interaction_list() -> void:
func test_stance_up_wire_mapping() -> void:
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
assert_that(wire).is_equal("ToggleStanceUp")
func test_stance_down_wire_mapping() -> void:
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
assert_that(wire).is_equal("ToggleStanceDown")
@@ -295,7 +295,7 @@ func test_all_movement_actions_have_wire_mapping() -> void:
InputMapper.Action.TOGGLE_STANCE_DOWN,
]
for action in actions_with_mapping:
var wire = SimBridge._action_enum_to_wire(action)
var wire = SimBridge.action_enum_to_wire(action)
assert_that(wire.length()).is_greater(0)
+6 -6
View File
@@ -163,23 +163,23 @@ func test_action_enum_to_wire_all_directions_clockwise() -> void:
]
for pair in expected:
var wire_name := SimBridge._action_enum_to_wire(pair[0])
var wire_name := SimBridge.action_enum_to_wire(pair[0])
assert_that(wire_name).is_equal(pair[1])
func test_action_enum_to_wire_non_movement() -> void:
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
func test_action_enum_to_wire_open_menu_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.OPEN_MENU)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.OPEN_MENU)
assert_that(wire_name).is_equal("")
func test_action_enum_to_wire_unknown_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(9999)
var wire_name := SimBridge.action_enum_to_wire(9999)
assert_that(wire_name).is_equal("")
+17 -12
View File
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
renderer.queue_free()
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var directions := {
"North": 0.0,
"Northeast": PI / 4.0,
"East": PI / 2.0,
"Southeast": 3.0 * PI / 4.0,
"South": PI,
"Southwest": 5.0 * PI / 4.0,
"West": 3.0 * PI / 2.0,
"Northwest": 7.0 * PI / 4.0,
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
var indicator = player_node.get_node_or_null("FacingIndicator")
for dir_name in directions:
GameState.player_facing = dir_name
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(_test_entities_v2)
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
renderer.queue_free()
func test_entity_renderer_npc_has_no_facing_indicator() -> void:
+222 -5
View File
@@ -1,8 +1,23 @@
extends Control
## #495: WRONG button (F12) MVP — bug report capture dialog.
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
## Upgrade of the Sprint 9 MVP (#495).
##
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
## - snapshot.json — single-tick point-in-time (MVP compat)
## - render.txt — client-side text render
## - description.txt — tester notes + room/tick/seed metadata
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
##
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
signal capture_completed
signal capture_cancelled
@@ -16,14 +31,172 @@ const BOX_WIDTH := 500
const BOX_HEIGHT := 120
const PADDING := 16
# #507: Ring buffer capacity — 60 ticks of history
const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
# Separate heads and counts so each buffer can be tested independently.
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
# debug tool that is always active during Gauntlet play.
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
var _snapshot_head: int = 0
var _snapshot_count: int = 0
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
# Pre-allocate ring buffers — resize then fill sentinels.
# The ring array itself never grows after _ready. Each write replaces the GDScript
# reference in an existing slot (not a new allocation of the ring), though the input
# Array stored per slot is a fresh ref each tick.
_input_ring.resize(RING_SIZE)
_snapshot_ring.resize(RING_SIZE)
for i in range(RING_SIZE):
_input_ring[i] = []
_snapshot_ring[i] = ""
# ---------------------------------------------------------------------------
# Public API for main.gd: record one tick's data
# ---------------------------------------------------------------------------
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
## - tick: current server tick number
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
## These are in raw InputMapper format and will be converted to replay format.
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
# Convert mapper inputs to replay-compatible format, then push both buffers.
var replay_inputs: Array = []
for inp in mapper_inputs:
var ri := _to_replay_format(inp, tick)
if not ri.is_empty():
replay_inputs.append(ri)
_push_tick_inputs(tick, replay_inputs)
_push_tick_snapshot(snapshot_json)
# ---------------------------------------------------------------------------
# Internal ring buffer operations (also exposed for tests)
# ---------------------------------------------------------------------------
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
## Overwrites oldest entry when buffer is full (circular eviction).
@warning_ignore("unused_parameter")
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
_input_ring[_input_head] = inputs
_input_head = (_input_head + 1) % RING_SIZE
if _input_count < RING_SIZE:
_input_count += 1
## Push a JSON-serialized ObserverSnapshot string for one tick.
func _push_tick_snapshot(snapshot_json: String) -> void:
_snapshot_ring[_snapshot_head] = snapshot_json
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
if _snapshot_count < RING_SIZE:
_snapshot_count += 1
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
## Returns a String with one JSON array per line, oldest to newest.
## Each line: Array of {tick, action} replay-format PlayerInput objects.
func _format_inputs_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
for i in range(_input_count):
var idx := (start + i) % RING_SIZE
lines.append(JSON.stringify(_input_ring[idx]))
return "\n".join(lines)
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
## Returns a String with one JSON string per line, oldest to newest.
func _format_snapshots_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
for i in range(_snapshot_count):
var idx := (start + i) % RING_SIZE
lines.append(_snapshot_ring[idx])
return "\n".join(lines)
## Return the RNG seed for seed.txt. Never returns null.
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
## loses precision above 2^53 via float intermediary. When the server field
## lands, consider string-encoding the seed to preserve all 64 bits.
func _get_current_seed() -> Variant:
if GameState.rng_seed != null:
return GameState.rng_seed
return "unavailable"
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
## Replay format: {"tick": N, "action": "MoveNorth"} or
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
var action_enum: int = input.get("action", -1)
var wire: String = SimBridge.action_enum_to_wire(action_enum)
if wire.is_empty():
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
var result := {"tick": tick}
var action_data: Variant = input.get("action_data")
match wire:
"Interact":
# Rust PlayerAction::Interact { target_entity_id, verb }
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
"SetFacing":
# Rust PlayerAction::SetFacing(String) — wrap direction string
var facing := ""
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
result["action"] = {"SetFacing": facing}
_:
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
result["action"] = wire
return result
# ---------------------------------------------------------------------------
# Test-accessible accessors (ring buffer introspection)
# ---------------------------------------------------------------------------
func _get_buffer_capacity() -> int:
return RING_SIZE
func _get_snapshot_buffer_capacity() -> int:
return RING_SIZE
func _get_input_buffer() -> Array:
return _input_ring
func _get_filled_input_count() -> int:
return _input_count
func _get_filled_snapshot_count() -> int:
return _snapshot_count
# ---------------------------------------------------------------------------
# UI / capture flow
# ---------------------------------------------------------------------------
func start_capture() -> void:
if _active:
@@ -94,7 +267,7 @@ func _save_report(description: String) -> void:
var files_saved := 0
# 1. snapshot.json — full current snapshot as JSON
# 1. snapshot.json — single-tick point-in-time (MVP compat, #495)
var snapshot_path := base_path + "/snapshot.json"
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
if snapshot_file:
@@ -115,6 +288,7 @@ func _save_report(description: String) -> void:
push_error("BugReport: failed to write %s" % render_path)
# 3. description.txt — tester description + metadata
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
var desc_path := base_path + "/description.txt"
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
if desc_file:
@@ -125,12 +299,53 @@ func _save_report(description: String) -> void:
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
desc_file.store_string("RingBufferTicks: %d\n" % _input_count)
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
# One JSON array per line. Empty array = idle tick.
# Compatible with tooling/test-client --replay (replay.rs).
var inputs_path := base_path + "/inputs.jsonl"
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
if inputs_file:
inputs_file.store_string(_format_inputs_jsonl())
inputs_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % inputs_path)
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
var snaps_path := base_path + "/snapshots.jsonl"
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
if snaps_file:
snaps_file.store_string(_format_snapshots_jsonl())
snaps_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snaps_path)
# 6. seed.txt — RNG seed for deterministic replay.
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
# If absent: includes a note on the required protocol change.
var seed_path := base_path + "/seed.txt"
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
if seed_file:
var seed_val: Variant = _get_current_seed()
seed_file.store_string(str(seed_val) + "\n")
if seed_val == "unavailable":
seed_file.store_string(
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
)
seed_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % seed_path)
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
## Simplified client-side text render of the current snapshot.
@@ -200,7 +415,9 @@ func _draw() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
# -- Public API ---------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func is_active() -> bool:
return _active
+134
View File
@@ -0,0 +1,134 @@
extends Control
## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode.
## Renders below the GauntletHUD timer. Each condition shows a check/dash + description.
## Only visible in gauntlet_mode. Latched conditions stay checked.
##
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
const FONT_SIZE := 11
const LINE_HEIGHT := 16
const PADDING := Vector2(8, 6)
const MAX_DESC_CHARS := 52 # Truncate long descriptions
var _evaluator = null # ChecklistEvaluator instance
var _last_room_id: Variant = null
var _cached_font: Font = null # Cached to avoid per-frame theme lookup
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_evaluator = _ChecklistEvaluator.new()
_cached_font = get_theme_default_font()
func update_from_state() -> void:
if not GameState.gauntlet_mode:
if visible:
visible = false
return
if not visible:
visible = true
var room_id: Variant = GameState.room_id
if room_id == null:
if _evaluator.is_loaded():
_evaluator.reset()
_last_room_id = null
queue_redraw()
return
# Load checklist on room change
if room_id != _last_room_id:
_evaluator.load_room(str(room_id))
_last_room_id = room_id
# Evaluate conditions against current snapshot
_evaluator.evaluate()
queue_redraw()
func _draw() -> void:
if _evaluator == null or not _evaluator.is_loaded():
return
var font: Font = _cached_font if _cached_font else get_theme_default_font()
var results: Array = _evaluator.get_results()
if results.is_empty():
return
var met_count: int = _evaluator.get_met_count()
var total_count: int = _evaluator.get_total_count()
var all_complete: bool = _evaluator.is_complete()
# Header line: "CHECK: 5/8"
var header_text := "CHECK: %d/%d" % [met_count, total_count]
var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR
# Calculate box height: header + one line per condition + padding
var line_count: int = 1 + results.size()
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
# Calculate box width from longest line
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
for r in results:
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
if line_width > max_width:
max_width = line_width
var box_width: float = max_width + PADDING.x * 2
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), 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, header_color)
# Condition lines
for r in results:
y += LINE_HEIGHT
var is_met: bool = r.get("met", false)
var prefix: String = "[x] " if is_met else "[ ] "
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var color: Color = MET_COLOR if is_met else UNMET_COLOR
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
# -- Public API ---------------------------------------------------------------
func get_evaluator():
return _evaluator
func get_met_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_met_count()
func get_total_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_total_count()
func is_complete() -> bool:
if _evaluator == null:
return false
return _evaluator.is_complete()
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"]
; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned
[node name="ChecklistOverlay" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -420.0
offset_top = 78.0
offset_right = -16.0
offset_bottom = 400.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_checklist")
+2 -2
View File
@@ -16,8 +16,8 @@ const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
+15
View File
@@ -13,6 +13,8 @@ extends PanelContainer
var _is_showing: bool = false
var _active_tween: Tween = null
var _current_target_id: int = -1
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
var _insert_active: bool = true
const FADE_IN: float = 0.15
const FADE_OUT: float = 0.15
@@ -23,6 +25,11 @@ func _ready() -> void:
_is_showing = false
func _process(_delta: float) -> void:
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
if not _insert_active:
if _is_showing:
_hide_prompt()
return
var interactions: Array = GameState.nearby_interactions
if interactions.size() > 0:
_show_prompt(interactions[0])
@@ -69,6 +76,14 @@ func _hide_prompt() -> void:
func get_interaction_target() -> int:
return _current_target_id
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
## Cursor shape changes still fire on cursor_renderer.gd.
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _is_showing:
_hide_prompt()
## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection).
func get_selected_verb() -> String:
var interactions: Array = GameState.nearby_interactions
+34 -15
View File
@@ -4,7 +4,9 @@ extends Control
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
## Insert spoke sends PauseSimulation on activate (#518/D-058).
## Selecting any non-Insert spoke, cancelling, or pressing Escape calls
## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent.
signal spoke_selected(spoke_name: String)
@@ -35,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
var _cached_font: Font = null
func _ready() -> void:
@@ -43,6 +46,7 @@ func _ready() -> void:
size = custom_minimum_size
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
_cached_font = get_theme_default_font()
func _input(event: InputEvent) -> void:
@@ -83,6 +87,15 @@ func _close_menu() -> void:
visible = false
# Handle Escape key to dismiss menu and deactivate insert if active.
func _unhandled_input(event: InputEvent) -> void:
if _open and event.is_action_pressed("ui_cancel"):
if _insert_active:
deactivate_insert()
_close_menu()
get_viewport().set_input_as_handled()
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
@@ -115,29 +128,36 @@ func _confirm_selection() -> void:
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
elif _insert_active:
# Selecting any non-Insert spoke closes the insert and resumes sim
deactivate_insert()
elif _insert_active:
# No spoke selected (cancelled) while insert active — close insert
deactivate_insert()
_close_menu()
func _activate_insert() -> void:
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
# #518/D-058: Send PauseSimulation when insert opens. Idempotent —
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
# #518/D-058: Send ResumeSimulation when insert closes.
if _insert_active:
_insert_active = false
_send_pause()
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func _draw() -> void:
@@ -174,10 +194,9 @@ func _draw() -> void:
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Batch audio generation from a manifest file. Whitelistable command.
# Usage: audio-batch manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
exec python3 "$(dirname "$0")/audio_batch.py" "$@"
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Batch audio generation from a manifest file.
Processes multiple assets sequentially: SAO generation or harmonic synthesis,
followed by post-processing (trim, normalize, convert to OGG).
Usage:
python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
"""
import json
import math
import os
import struct
import subprocess
import sys
import wave
import numpy as np
def load_manifest(path):
with open(path) as f:
return json.load(f)
def resolve_paths(manifest, manifest_dir):
"""Resolve output_dir and gen_dir relative to the git root."""
# Find git root by walking up from manifest_dir
# Check for .git as file (worktree) or directory (regular repo)
git_root = manifest_dir
while git_root != "/":
if os.path.exists(os.path.join(git_root, ".git")):
break
git_root = os.path.dirname(git_root)
else:
git_root = manifest_dir
output_dir = os.path.join(git_root, manifest.get("output_dir", "client/assets/audio"))
gen_dir = os.path.join(git_root, manifest.get("gen_dir", "client/assets/audio/gen"))
os.makedirs(output_dir, exist_ok=True)
os.makedirs(gen_dir, exist_ok=True)
return output_dir, gen_dir, git_root
def get_default(manifest, asset, key):
"""Get a value from the asset, falling back to manifest defaults."""
defaults = manifest.get("defaults", {})
return asset.get(key, defaults.get(key))
def run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir):
"""Generate audio via Stable Audio Open + post-processing."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + ".wav")
ogg_path = os.path.join(output_dir, filename)
prompt = asset["prompt"]
duration = asset.get("duration", 10)
steps = get_default(manifest, asset, "steps") or 100
cfg = get_default(manifest, asset, "cfg") or 7
timeout = get_default(manifest, asset, "timeout") or 600
# Run audio-generate with --post
cmd = [
sys.executable, os.path.join(script_dir, "audio_connector.py"),
"generate", prompt,
"--duration", str(duration),
"--steps", str(steps),
"--cfg", str(cfg),
"--output", wav_path,
"--output-ogg", ogg_path,
"--timeout", str(timeout),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
if result.returncode != 0:
stderr = result.stderr.strip()
try:
err = json.loads(result.stdout)
return {"ok": False, "error": err.get("error", stderr)}
except (json.JSONDecodeError, ValueError):
return {"ok": False, "error": stderr or "generation failed"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": False, "error": f"Unexpected output: {result.stdout[:200]}"}
def synthesize_harmonic(params, wav_path):
"""Synthesize audio from harmonic parameters."""
sr = 44100
duration = params["duration"]
fundamental = params["fundamental"]
harmonics = params.get("harmonics", [])
attack_ms = params.get("attack_ms", 10)
sustain_ratio = params.get("sustain_ratio", 0.2)
decay = params.get("decay", "exponential")
n = int(sr * duration)
t = np.linspace(0, duration, n, endpoint=False)
# Fundamental
signal = np.sin(2 * np.pi * fundamental * t)
# Add harmonics
for h in harmonics:
freq = h["freq"]
db = h["db"]
amplitude = 10 ** (db / 20)
signal = signal + amplitude * np.sin(2 * np.pi * freq * t)
# Envelope: attack + sustain + decay
attack_s = attack_ms / 1000
attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n)
sustain_end = duration * sustain_ratio
if decay == "exponential":
# Decay rate: reach -60dB by end of duration
decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10
decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end)))
else:
# Linear decay
decay_env = np.where(t < sustain_end, 1.0,
1.0 - (t - sustain_end) / (duration - sustain_end))
envelope = attack_env * decay_env
signal = signal * envelope
# Normalize to peak
peak = np.max(np.abs(signal))
if peak > 0:
signal = signal / peak * 0.9
# Write WAV
int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16)
with wave.open(wav_path, "w") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(sr)
f.writeframes(int_samples.tobytes())
return wav_path
def run_synth(asset, manifest, gen_dir, output_dir, script_dir):
"""Synthesize audio from harmonic parameters + post-process."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + "_synth.wav")
ogg_path = os.path.join(output_dir, filename)
synth_params = asset.get("synth")
if not synth_params:
return {"ok": False, "error": "No synth parameters provided"}
synth_type = synth_params.get("type", "harmonic")
if synth_type != "harmonic":
return {"ok": False, "error": f"Unknown synth type: {synth_type}"}
try:
synthesize_harmonic(synth_params, wav_path)
except Exception as e:
return {"ok": False, "error": f"Synthesis failed: {e}"}
# Post-process: normalize + convert (skip trim for synth — no silence to trim)
post_script = os.path.join(script_dir, "audio_post.py")
lufs = get_default(manifest, asset, "lufs") or -16
quality = get_default(manifest, asset, "quality") or 6
# Normalize
norm_path = os.path.join(gen_dir, base_name + "_norm.wav")
cmd = [sys.executable, post_script, "normalize", wav_path, "--output", norm_path,
"--lufs", str(lufs)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Normalize failed: {result.stderr.strip()}"}
# Convert to OGG
cmd = [sys.executable, post_script, "convert", norm_path, "--output", ogg_path,
"--quality", str(quality)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Convert failed: {result.stderr.strip()}"}
# Clean up intermediate
try:
os.remove(norm_path)
except OSError:
pass
ogg_size = os.path.getsize(ogg_path)
return {
"ok": True,
"file": wav_path,
"ogg_file": ogg_path,
"ogg_size_bytes": ogg_size,
"synth_params": synth_params,
"post_processed": True,
}
def main():
if len(sys.argv) < 2:
print("Usage: audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]",
file=sys.stderr)
sys.exit(1)
manifest_path = sys.argv[1]
dry_run = "--dry-run" in sys.argv
skip_existing = "--skip-existing" in sys.argv
only_ids = None
for i, arg in enumerate(sys.argv):
if arg == "--only" and i + 1 < len(sys.argv):
only_ids = set(sys.argv[i + 1].split(","))
manifest = load_manifest(manifest_path)
manifest_dir = os.path.dirname(os.path.abspath(manifest_path))
output_dir, gen_dir, git_root = resolve_paths(manifest, manifest_dir)
script_dir = os.path.dirname(os.path.abspath(__file__))
assets = manifest.get("assets", [])
if only_ids:
assets = [a for a in assets if a["id"] in only_ids]
# Health check if any SAO assets
sao_assets = [a for a in assets if a.get("method") == "sao"]
if sao_assets and not dry_run:
print(f"Checking SAO API health...", file=sys.stderr)
health_cmd = [sys.executable, os.path.join(script_dir, "audio_connector.py"), "health"]
result = subprocess.run(health_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(json.dumps({"ok": False, "error": "SAO API health check failed",
"details": result.stdout.strip()}))
sys.exit(1)
print(f" SAO API is up.", file=sys.stderr)
total = len(assets)
results = []
success = 0
failed = 0
skipped = 0
print(f"Processing {total} assets from {os.path.basename(manifest_path)}...", file=sys.stderr)
if dry_run:
print(" (dry run — no generation will occur)", file=sys.stderr)
for i, asset in enumerate(assets, 1):
asset_id = asset["id"]
filename = asset["filename"]
method = asset.get("method", "sao")
print(f"\n[{i}/{total}] {asset_id}: {filename} ({method})", file=sys.stderr)
if skip_existing:
ogg_path = os.path.join(output_dir, filename)
if os.path.exists(ogg_path):
print(f" Skipping — already exists", file=sys.stderr)
results.append({"id": asset_id, "status": "skipped", "reason": "exists"})
skipped += 1
continue
if dry_run:
print(f" Would generate: {filename}", file=sys.stderr)
if method == "sao":
print(f" Prompt: {asset.get('prompt', '(none)')[:80]}...", file=sys.stderr)
elif method == "synth":
synth = asset.get("synth", {})
print(f" Synth: {synth.get('fundamental')}Hz, {synth.get('duration')}s",
file=sys.stderr)
results.append({"id": asset_id, "status": "dry_run"})
continue
if method == "sao":
result = run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir)
elif method == "synth":
result = run_synth(asset, manifest, gen_dir, output_dir, script_dir)
else:
result = {"ok": False, "error": f"Unknown method: {method}"}
result["id"] = asset_id
if result.get("ok"):
success += 1
result["status"] = "success"
print(f" OK → {result.get('ogg_file', filename)}", file=sys.stderr)
else:
failed += 1
result["status"] = "failed"
print(f" FAILED: {result.get('error', 'unknown')}", file=sys.stderr)
results.append(result)
# Summary
summary = {
"ok": failed == 0,
"total": total,
"success": success,
"failed": failed,
"skipped": skipped,
"results": results,
}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
+55 -5
View File
@@ -6,12 +6,13 @@ Talks to the Stable Audio Open Gradio app at tower-of-joy:11500.
Uses the async Gradio API pattern: POST to submit, SSE stream for results.
Usage:
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav]
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav] [--post]
python3 audio_connector.py health
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
@@ -56,7 +57,31 @@ def health():
}, indent=2))
sys.exit(1)
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600):
def post_process(wav_path, ogg_path=None, lufs=-16, quality=6, threshold=-50):
"""Run trim + normalize + convert on a WAV file via audio-post pipeline."""
script_dir = os.path.dirname(os.path.abspath(__file__))
post_script = os.path.join(script_dir, "audio_post.py")
if ogg_path is None:
ogg_path = os.path.splitext(wav_path)[0] + ".ogg"
cmd = [
sys.executable, post_script, "pipeline", wav_path,
"--output", ogg_path,
"--lufs", str(lufs),
"--quality", str(quality),
"--threshold", str(threshold),
]
print(f" Post-processing → {os.path.basename(ogg_path)}...", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Post-processing failed: {result.stderr.strip()}"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": True, "output": ogg_path}
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600,
post=False, output_ogg=None):
"""
Generate audio from a text prompt.
@@ -67,6 +92,8 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
cfg: Classifier-free guidance scale (default 7)
output: Output file path (default: auto-named in current directory)
timeout: Maximum wait time in seconds (default 600 = 10 minutes)
post: If True, run trim+normalize+convert after generation
output_ogg: OGG output path when post=True (default: same basename .ogg)
"""
base = get_base_url()
api_url = f"{base}/gradio_api/call/generate_audio"
@@ -216,7 +243,7 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
shutil.copyfileobj(resp, f)
file_size = os.path.getsize(output)
print(json.dumps({
result_json = {
"ok": True,
"file": output,
"size_bytes": file_size,
@@ -225,7 +252,20 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
"cfg": cfg,
"prompt": prompt,
"generation_time_s": elapsed
}, indent=2))
}
if post:
post_result = post_process(output, ogg_path=output_ogg)
if not post_result.get("ok"):
result_json["post_processed"] = False
result_json["post_error"] = post_result.get("error", "unknown")
else:
result_json["post_processed"] = True
result_json["ogg_file"] = post_result.get("output", output_ogg)
ogg_size = os.path.getsize(result_json["ogg_file"])
result_json["ogg_size_bytes"] = ogg_size
print(json.dumps(result_json, indent=2))
except Exception as e:
print(json.dumps({
@@ -258,6 +298,8 @@ def main():
cfg = 7.0
output = None
timeout = 600
post = False
output_ogg = None
# Parse optional args
i = 3
@@ -277,11 +319,19 @@ def main():
elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv):
timeout = int(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--post":
post = True
i += 1
elif sys.argv[i] == "--output-ogg" and i + 1 < len(sys.argv):
output_ogg = sys.argv[i + 1]
post = True # --output-ogg implies --post
i += 2
else:
print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr)
sys.exit(1)
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output, timeout=timeout)
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output,
timeout=timeout, post=post, output_ogg=output_ogg)
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
+22 -1
View File
@@ -159,6 +159,27 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Inigo (insert-tech/organic split), Paula (cognitive architecture framing and trust model connection)
- **Dissent:** None
### D-075: Dialogue filtering — layered confidence gate on trust tier (OQ-18 resolution)
- **Date:** 2026-02-19
- **Decision:** KnowledgeConfidence ([D-041](architecture.md#d-041-knowledge-graph-data-model)) acts as a co-gate on TrustTier ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) Layer 3), not on AccessTier (Layer 1). Access tier and trust tier remain two separate filtering dimensions with different drivers:
1. **AccessTier (Layer 1):** Gated by `RelationshipState` only. Social position determines what *categories* of dialogue are available. No change from current implementation. Character archetype effects are emergent — the detective's investigation naturally creates PersonOfInterest relationships (Authority access), the smuggler's social arc naturally creates Known/Friendly relationships (Peer/Insider access). No archetype tag on the pipeline.
2. **TrustTier (Layer 3):** Gated by both `RelationshipState` AND `KnowledgeConfidence`:
- **Surface:** any relationship + any confidence — baseline, always available.
- **Real:** (Friendly or Known) + KnowsOf+ — requires both rapport and substantive knowledge.
- **Secret:** Friendly + KnowsDetails+ — requires both deep rapport and actionable knowledge.
3. **KnowledgeConfidence does NOT gate AccessTier.** Access is a social/positional concept ("who are you to me?"), not an information concept ("what do you know about me?"). A stranger can have Authority access (detective flashes badge) with zero knowledge. An insider can have Peer access before they know anything specific about the target.
- **Key design choice — no archetype dimension.** The dialogue pipeline does not add a character-archetype tag (detective/smuggler) as a filtering axis. Instead, archetype effects on dialogue emerge from: (a) different starting RelationshipStates driven by gameplay (detective institutions → Authority access, smuggler social network → Insider access), (b) different knowledge accumulation rates (detective's analytical lattice gains KnowsOf faster → Real trust earlier), (c) D-028 Layer 1 access tags on lines already encode "this line is for authority figures" vs "this line is for insiders." This is architecturally consistent with D-010 principle 3 (no baking player identity into the game loop).
- **Rationale:** Three reasons for layered-but-not-archetype:
1. *Separation of concerns.* Access (social position) and trust (relationship depth x knowledge depth) answer different questions. Collapsing them into one axis would require rewriting D-028's four-layer model and D-035's tag taxonomy — both confirmed and implemented.
2. *Minimal code change.* The only implementation change is adding a `KnowledgeConfidence` parameter to `relationship_to_trust()` in `server/src/simulation/dialogue.rs`. The caller already has access to the observer's KnowledgeGraph. No new components, no new tags, no content format changes.
3. *Emergent archetype distinction.* Hardcoding archetype tags creates a maintenance burden (new character = new tag = new content variant) and reduces the "two keyholes on the same world" experience. When the detective and smuggler experience different dialogue from the same NPC, it should be because they have different *relationships* and *knowledge*, not because a tag excluded them.
- **Implementation change to #305:** `relationship_to_trust()` gains a `confidence` parameter. Mapping: `(Friendly, KnowsDetails+) → Secret`, `(Friendly|Known, KnowsOf+) → Real`, `(_ , _) → Surface`. Caller in `process_talk_interaction` passes `observer_kg.confidence_of(&target_sid)` to the updated function.
- **Resolves:** OQ-18
- **Amends:** [D-041](architecture.md#d-041-knowledge-graph-data-model) (confirms confidence-to-trust mapping; supersedes the preliminary 1:1 sketch in D-041 "Key design choices" bullet 3 with the layered model above), [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) (Layer 3 trust now requires both relationship AND confidence)
- **Cross-reference:** [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers), [D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-062](#d-062-invisible-locked-dialogue-options) (confidence progression naturally unlocks new trust tiers, creating the "new options appearing" reward)
- **Raised by:** Tyre (technical analysis, architecture synthesis)
- **Dissent:** Pending review. Sprint briefing flags Gestalt and Nigel for archetype dimension input.
---
*15 decisions. Last updated: 2026-02-16*
*16 decisions. Last updated: 2026-02-19*
+10 -1
View File
@@ -212,6 +212,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Araminta (visual spec), Stig (UX rules + diegetic test), Ozzie (weapon suppression)
- **Dissent:** None.
- **OQ-07 resolution (2026-02-19, #522):** Insert-off behavior is **option (a): cursor shape still changes, verb labels suppressed.**
- Cursor state machine fires normally (entity hover → bracket shape, object hover → X-shape) — the character's body physically orients toward targets as a subconscious/spatial response.
- Insert does not process targets into actionable data: `should_show_interactions()` returns false when `insert_active == false`, and interaction labels (z-layer 6) are hidden via `set_insert_active(false)` on `InteractionList` and `InteractionPrompt`.
- `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`).
- Rationale: diegetically consistent — the body reacts to proximity; the insert reacts to commands.
### D-057: Entity interaction — vertical list, insert-styled
- **Date:** 2026-02-13
@@ -224,6 +229,10 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (vertical list structure + diegetic test), Araminta (insert aesthetic), Dudley (two-phase verb computation), Nigel (character-archetype verb sets). Lead resolved: Stig's structure, Araminta's styling.
- **Dissent:** Araminta argued for spoke radial (geometry transformation signals qualitative knowledge change — new spoke growing). Lead rejected: items moving under cursor when knowledge changes is a moving goalpost (bad UX while aiming at an option).
- **OQ-07 resolution (2026-02-19, #522):**
- When `insert_active == false`, the interaction list hides completely (`set_insert_active(false)``_hide()`).
- Cursor shape changes still occur per D-056 OQ-07 — list suppression is independent of cursor state.
- `GameState.insert_active` drives this at runtime, wired via `main.gd` on each snapshot.
### D-058: World menu — radial, 4 spokes
- **Date:** 2026-02-13
@@ -338,4 +347,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
---
*29 decisions. Last updated: 2026-02-16*
*29 decisions. Last updated: 2026-02-19 (OQ-07 resolved: D-056/D-057 amendment)*
+12 -1
View File
@@ -152,6 +152,17 @@ Tracked questions awaiting discussion or resolution.
- **Assigned to:** Gestalt, Paula, Dudley
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1)
### Q-027: Fast-travel system design
- **Status:** Open
- **Question:** How does inter-system travel work in production gameplay? The current hub teleport (Home key, #501) is scoped as Gauntlet-only dev tool. Production travel must be diegetic and respect asymmetric information. Proposed flow: player goes to local gate → warps to system gate → interacts with target menu → jumps to destination system gate. Key constraints:
1. **Region gating:** fast-travel only available from safe or fast-travel-enabled regions. If you rented transport to reach a remote location (e.g. mountain colony), you must return the transport to civilization first — this can be a skip-travel interaction but must happen in-world.
2. **Asymmetric information:** NPCs observe arrivals and departures. Travel choices leak information (who saw you leave, who sees you arrive, what transport was used).
3. **Home key in production:** at most, Home could prompt "Do you want to fast-travel to the system hub?" if in a safe/enabled region — never instant teleport.
4. **Transport types:** walking, rented vehicle, public transit, gate network — each with different information exposure profiles.
- **Context:** #501 implemented instant Home key teleport gated behind `gauntlet_mode`. Re-scoped to Gauntlet-only after design review. Production fast-travel needs separate design and implementation.
- **Assigned to:** Gestalt, Paula, Tyre
- **Source:** Sprint 10 PR review discussion (2026-02-19)
---
*26 questions (3 resolved, 2 partially resolved, 21 open). Last updated: 2026-02-11*
*27 questions (3 resolved, 2 partially resolved, 22 open). Last updated: 2026-02-19*
+1 -1
View File
@@ -281,7 +281,7 @@ The sync runs automatically as part of `make setup` and via pre-commit hook. Mar
## Commit Conventions
See the `/commit` skill (`.claude/skills/commit/`) for full details. Summary:
See the `/git-commit` skill (`.claude/skills/git-commit/`) for full details. Summary:
- Conventional commits: `type(scope): summary`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `data`, `loc`
+4 -4
View File
@@ -14,10 +14,10 @@ Stable Audio Open primary. Prompt strategy: describe the SPACE, not the sound.
| ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint |
|----|----------|--------|-----|--------|----------|-------------|--------|
| AMB-001 | `amb_station_base.ogg` | planned | Ambient | SAO | 45-60s loop | Station hum, span gate vibration, ventilation. Low-freq foundation (60-120Hz), mid texture (200-800Hz). Always playing — acoustic foundation of being on a station. | S8+ |
| AMB-002 | `amb_workplace_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Cargo machinery, scanner pings, distant procedural voices (designations, not conversation). Rhythmic mechanical cadence. Terminal zone overlay. | S8+ |
| AMB-003 | `amb_bar_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Conversation murmur (300Hz-3kHz), softened glass sounds, faint Meridian music (barely melodic, distant radio). Social warmth. Murmur baked in — bar IS the crowd. Last Shift zone overlay. | S8+ |
| AMB-004 | `amb_corridor_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Echoing footsteps, ventilation whistle with reverb tail, louder gate hum (closer to structure). Emptier mid-range, prominent low-end. Liminal emptiness. Corridor zone overlay. | S8+ |
| AMB-001 | `amb_station_base.ogg` | done | Ambient | SAO | 43.3s loop | Station hum, span gate vibration, ventilation. Low-freq foundation (60-120Hz), mid texture (200-800Hz). Always playing — acoustic foundation of being on a station. SAO 45s/150 steps/CFG 5, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-002 | `amb_workplace_layer.ogg` | done | Ambient | SAO | 42.9s loop | Cargo machinery, scanner pings, distant procedural voices (designations, not conversation). Rhythmic mechanical cadence. Terminal zone overlay. SAO 45s/150 steps/CFG 6, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-003 | `amb_bar_layer.ogg` | done | Ambient | SAO | 43.6s loop | Conversation murmur (300Hz-3kHz), softened glass sounds, faint Meridian music (barely melodic, distant radio). Social warmth. Murmur baked in — bar IS the crowd. Last Shift zone overlay. SAO 45s/150 steps/CFG 6, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-004 | `amb_corridor_layer.ogg` | done | Ambient | SAO | 42.7s loop | Echoing footsteps, ventilation whistle with reverb tail, louder gate hum (closer to structure). Emptier mid-range, prominent low-end. Liminal emptiness. Corridor zone overlay. SAO 45s/150 steps/CFG 5, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
## Detailed Entries
+2 -2
View File
@@ -13,8 +13,8 @@ Mixed methods depending on duration and character:
| ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint |
|----|----------|--------|-----|--------|----------|-------------|--------|
| SFX-001 | `sfx_footstep_metal.ogg` | planned | Player Actions | hybrid | 0.3-0.5s | Single footstep on metal grating. Walk pace. Player character proprioceptive feedback. | S8+ |
| SFX-002 | `sfx_footstep_metal_run.ogg` | planned | Player Actions | hybrid | 0.2-0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). | S8+ |
| SFX-001 | `sfx_footstep_metal_walk.ogg` | done | Player Actions | SAO hybrid | 0.3s | Single footstep on metal grating. Walk pace. SAO 5s/100 steps/CFG 8, best transient trimmed at 2.42s, LUFS -16 normalized. | S10 #327 |
| SFX-002 | `sfx_footstep_metal_run.ogg` | done | Player Actions | SAO hybrid | 0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). SAO 5s/100 steps/CFG 8, best transient trimmed at 1.03s, LUFS -16 normalized. | S10 #327 |
## Detailed Entries
+3 -3
View File
@@ -5,7 +5,7 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
## Generation Approach
- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`.
- **Monologue chimes:** Re-generated in Sprint 9 (#453). Insert-tech prefix + Chimes template, 5s SAO generation trimmed to 0.8s.
- **Monologue chimes:** Replaced in Sprint 10 (#327) with manual synthesis. Insert-tech aesthetic: pure sine harmonics, mathematical envelope, no SAO. Previous S9 SAO versions were acknowledged placeholders per D-038 amendment.
## Assets
@@ -15,8 +15,8 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
| UI-002 | `implant_open.ogg` | done | UI Sounds | SAO | 217ms | Rising tone when insert UI opens (radial menu, inventory, stance). Neural lattice powering up. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-003 | `fog_recognition.ogg` | done | UI Sounds | SAO | 400ms | Warm organic chime at onset of cognitive delay (D-060). Re-generated in S9 — original was silent. Organic prefix + Chimes template. | S7 #440, S9 #453 |
| UI-004 | `weapon_aim.ogg` | done | UI Sounds | SAO | 127ms | Harder click/lock for weapon aim state. Mechanical, deliberate. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template. Crystalline tone, neural lattice surfacing a thought. See design brief below. | S7 #440, S9 #453 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template (sharper/brighter variant). CFG 8. See design brief below. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | manual synthesis | 800ms | Production-quality manual synthesis in S10. Insert-tech: 1200Hz fundamental + harmonics (2400Hz -12dB, 3600Hz -24dB, 6000Hz -30dB). 15ms attack, 0.2 sustain ratio, exp decay. Peak -15dB. No SAO — pure mathematical precision. | S7 #440, S9 #453, S10 #327 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | manual synthesis | 800ms | Production-quality manual synthesis in S10. Insert-tech: 1220Hz fundamental (detuned +20Hz for tension) + stronger harmonics (2440Hz -6dB, 3660Hz -18dB, 5087Hz -24dB, inharmonic 5087Hz -28dB). 8ms attack, 0.3 sustain ratio. Peak -6.3dB. | S7 #440, S9 #453, S10 #327 |
| UI-007 | `sfx_weapon_aim_lock.ogg` | done | UI Sounds | SAO | 505ms | Sharp targeting lock-on confirmation for weapon aim. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
| UI-008 | `sfx_stance_change.ogg` | done | UI Sounds | SAO | 349ms | Soft mechanical mode-switch click for stance toggle. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
+1 -1
View File
@@ -19,7 +19,7 @@ See [palette.md](palette.md) for art direction, "the angle", color relationships
## Pipeline
- **3D render pipeline:** Blender model → Godot Camera3D at -72.5deg → orthographic render → outline pass → resolution downscale
- **Render skill:** `/render-sprite` produces 12 PNGs (4 directions x 3 resolutions)
- **Render skill:** `/sprite-gen` produces 12 PNGs (4 directions x 3 resolutions)
- **Art direction workshop:** `docs/workshops/art-direction-mood-board/`
## Decision References
Binary file not shown.
+12
View File
@@ -7,4 +7,16 @@ Personal notes and random thoughts. Not acted upon unless explicitly instructed.
- Pipeline to create postcards for each world — visuals for in-game dossiers
- Investigate using Veo to create gate transition movies — based on planetary profiles, postcards, and game visual style
- Capture discussion with Gemini about setting up a 3D to 2D pipeline
- Improve asset generation pipeline: proper asset registry, status tracking per asset, prompt storage/versioning, pre-sprint prompt preparation pass to maintain visual/audio cohesion across batches
- Remote terminal proxy for Claude Code — mobile notification + interaction bridge for monitoring progress away from desk. Full feature scope:
- Capture permission prompts across all terminals/worktrees in a project
- Push notifications to phone (Ntfy/Gotify/Pushover/custom — self-hostable)
- Summary of last assistant message for context (what Claude is doing and why it's asking)
- Support AskUserQuestion mode: render multiple-choice options, free-text input
- Support permission approve/deny with the tool call details
- Basically a full terminal proxy that adapts to Claude Code's interactive elements
- Starting point: peon-ping already hooks into the right events (PermissionRequest, Notification, Stop, etc.) via ~/.claude/hooks/
- Hook event JSON includes: hook_event_name, notification_type, session_id, cwd, permission_mode
- Gap: hook event data may not include prompt content (tool call details, AskUser question text, multiple choice options) — investigate what's in the full event payload
- Architecture: extend peon-ping's send_notification pattern to push to a remote service, add a response channel back to the terminal
+83
View File
@@ -0,0 +1,83 @@
# Sprint 10: Prove — Audio Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `audio`
**Agents:** Inigo (sound design)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #327 | Audio asset set — 8 minimum viable files via Stable Audio Open | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-038 (audio in v0.1 — 8 files spec, hybrid generation approach)
- `decisions/perception.md` — D-067 (recognition chime fires at onset of cognitive delay), D-069 (audio dip profiles), D-071 (ListeningFocus boost), D-072 (universal event-driven conversation murmur)
- `decisions/architecture.md` — D-068 (5-bus audio architecture), D-073 (zone crossfade)
- `decisions/content.md` — D-074 (audio aesthetic identity — insert-tech vs organic)
---
## Notes
**#327 — Audio asset set — 8 minimum viable files**
- Existing: `AudioManager` autoload at `client/scripts/autoloads/audio_manager.gd` is fully implemented (D-068). The directory-scan registry pattern is in place — drop OGG files into `res://audio/` and they register automatically. Sprint 9 delivered 6 interaction SFX and 2 revised monologue chimes (#440, #453).
- What this ticket adds: The 8 ambient and world-SFX assets specified in D-038. These are distinct from the Sprint 9 interaction sounds — they cover the continuous soundscape and footstep system.
**The 8 files (D-038):**
| Key | File | Duration | Bus | Method |
|-----|------|----------|-----|--------|
| `amb_station_base` | `amb_station_base.ogg` | 60-90s | Ambient | SAO — station hum + span gate vibration |
| `amb_workplace_layer` | `amb_workplace_layer.ogg` | 45-60s | Ambient | SAO — cargo machinery |
| `amb_bar_layer` | `amb_bar_layer.ogg` | 45-60s | Ambient | SAO — conversation murmur baked in |
| `amb_corridor_layer` | `amb_corridor_layer.ogg` | 45-60s | Ambient | SAO — ventilation/subtle hum |
| `sfx_footstep_metal_walk` | `sfx_footstep_metal_walk.ogg` | 0.2-0.3s | WorldSFX | SAO or manual — one footstep, walk cadence |
| `sfx_footstep_metal_run` | `sfx_footstep_metal_run.ogg` | 0.2-0.3s | WorldSFX | SAO or manual — one footstep, run cadence |
| `sfx_monologue_chime` | `sfx_monologue_chime.ogg` | 0.5-1.0s | UISounds | Manual synthesis — crystalline, neural-lattice feel |
| `sfx_monologue_chime_urgent` | `sfx_monologue_chime_urgent.ogg` | 0.5-1.0s | UISounds | Manual synthesis — sharper, contradiction/anomaly variant |
**D-074 aesthetic split — critical:**
- Ambient layers and footsteps = **organic**: warm, breathy, environmental reverb, natural decay. Use SAO for these.
- Monologue chimes = **insert-tech**: synthetic, precise, clinical, no reverb. Manually synthesize — SAO will produce too organic a character for chimes.
- The Sprint 9 chimes (#453) are acknowledged placeholders. Sprint 10 chimes are the production-quality replacements.
**D-038 amendment (hybrid approach):**
- SAO ceiling is ~47s. Accept 45s loops with crossfade for ambient layers (the `play_loop` method in AudioManager handles this).
- Sounds <200ms with precise/digital character: manual synthesis only.
**Zone crossfade integration:**
- `AudioManager.set_zone()` is currently a stub (D-073). This sprint's audio assets enable testing the stub's behavior. Inigo should verify that `amb_station_base`, `amb_workplace_layer`, `amb_bar_layer`, and `amb_corridor_layer` all loop cleanly with no audible seam — AudioManager's `_enable_loop` will set `loop = true` on OGG streams automatically.
- Full zone crossfade logic (the tween between ambient layers on zone change) is NOT in scope for this sprint — it remains a stub. The assets just need to be present and looping.
**Delivery:**
- Commit all 8 OGG files to `audio` branch under `client/audio/` (which maps to `res://audio/` in Godot).
- AudioManager registers them at startup via directory scan. No code changes to client branch.
- Effort: 3-4d (generation + iteration + mastering).
---
## Dependency Chain
```
#327 (8 audio assets) — standalone; AudioManager stub (D-068) already in client branch
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(audio): sprint 10 prove — ambient + world sfx assets" --description "body" --base main --head audio
```
+68
View File
@@ -0,0 +1,68 @@
# Sprint 10: Prove — CI Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `ci`
**Agents:** Justine (build/deploy), Hoshe (QA review)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #499 | Performance baseline tooling (make perf-baseline) | #487 (done) |
| #510 | CI pipeline (Gitea Actions YAML, 3-tier) | — |
| #515 | Bidirectional relationship consistency warning (content validation check 9) | #464 (done) |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-030 (testing architecture phases), D-026 (100ms tick budget)
- `decisions/scope.md` — D-038 (audio in v0.1 — audio asset presence/absence affects `make ci` behavior)
---
## Notes
**#499 — Performance baseline tooling**
- Existing: `Makefile` has `make pre-pr`, `make test-server`, `make test-client` targets. The test infrastructure (Sprint 9) is complete. No `perf-baseline` target exists yet.
- Deliver: `make perf-baseline` target. Runs the server benchmark suite, captures tick timing, memory usage, and entity count scaling metrics, outputs results to `tests/perf/`. The baseline file should be committed and tracked in git so future runs can detect regressions.
- Integration: Server #500 (content scaling test) feeds into this — coordinate timing so #499 can include the content scaling numbers in the baseline output. If #500 isn't merged, the perf baseline can run with existing content and be updated when #500 lands.
**#510 — CI pipeline (Gitea Actions YAML, 3-tier)**
- Existing: `make pre-pr` exists and is the manual gate. Gitea Actions self-hosted runner is available.
- Deliver: A Gitea Actions workflow YAML (`/.gitea/workflows/ci.yaml` or `.gitea/workflows/`) implementing three tiers:
- Commit tier: `<2min` — lint only (`make lint-server`, `make lint-client`)
- PR merge gate: `<15min` — wraps `make pre-pr`
- Nightly: `<30min` — full build + all tests + content validation
- **Deferred condition**: This ticket is backlog-tagged "deferred until lead greenlights." Confirm with Team Leader before starting. If greenlit, coordinate with Tyre on self-hosted runner configuration.
- Integration: Wraps existing `make` targets — should require zero changes to those targets.
**#515 — Bidirectional relationship consistency warning**
- Existing: Content validation runs via `tooling/validate-content`. Phase 1-2 checks are complete (#464 done). Check 9 is advisory.
- Deliver: Advisory warning (non-blocking, not an error) when NPC A has a relationship to NPC B but no reciprocal entry exists in NPC B's relationships. Output format: `WARN: NPC 'kael' has relationship to 'sera' but 'sera' has no reciprocal entry.` Effort: 0.25d.
- Non-obvious: One-sided relationships are sometimes intentional (faction member knows of faction leader, not vice versa). The warning is advisory, never a hard failure.
---
## Dependency Chain
```
#499 (perf baseline) — standalone (server #487 done); benefits from server #500 merging first
#510 (CI pipeline) — standalone; greenlight from lead required before start
#515 (bidirectional warning) — standalone (#464 done)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): sprint 10 prove — ci" --description "body" --base main --head ci
```
+109
View File
@@ -0,0 +1,109 @@
# Sprint 10: Prove — Client Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `client`
**Agents:** Stig (UI/client dev), Tyre (arch review), Hoshe (QA)
---
## Carry-over from Sprint 9
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #501 | Hub teleport client UX (Home key, fade transition) | backlog | Targeted Sprint 9, slipped — blocked by #491 server work which also slipped. Unblocks immediately once server #491 ships. |
| #502 | Room reset client UX (reset plate tile, interaction verb, amber flash) | backlog | Targeted Sprint 9, slipped. Unblocked (#490 done). |
| #503 | Auto-checklist progress tracking (client-side snapshot evaluation) | backlog | Targeted Sprint 9, slipped. Unblocked (#497 done). |
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #501 | Hub teleport client UX (Home key, fade transition) | #491 server (Sprint 10) |
| #502 | Room reset client UX (reset plate tile, interaction verb, amber flash) | #490 (done) |
| #503 | Auto-checklist progress tracking (client-side snapshot evaluation) | #497 (done) |
| #517 | Introduce Michroma as game font | — |
| #526 | Client: mouse-relative facing and movement (D-054) | — |
| #518 | Client: wire insert open/close to PauseSimulation command | — |
| #521 | Confrontation D-033 color shift — client-side entity tint | #520 server (Sprint 10) |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot/Rust IPC), D-054 (mouse-relative facing), D-058 (auto-pause on insert open), D-066 (dual-scale grid)
- `decisions/perception.md` — D-033 (entity color = relationship to player), D-056 (insert diegetic visibility), D-057 (interaction verbs), D-060 (cognitive delay), D-063 (confrontation same box)
- `decisions/scope.md` — D-053 (movement stance toggle)
---
## Open Questions to Resolve Early
- **Q-019 (partial): Client-side entity lifecycle / StableId mapping**#521 reads relationship state from `ObserverSnapshot` and adjusts entity tint. Confirm with server team that relationship state is included in the snapshot before #521 starts. Resolve before #521 begins.
---
## Notes
**#501 — Hub teleport client UX**
- Existing: `client/scripts/protocol/protocol.gd` handles PlayerAction serialization. `client/scripts/rendering/world_renderer.gd` handles camera.
- Deliver: Home key sends `PlayerAction::TeleportToHub`. On receipt: instant camera snap to hub spawn position + 0.3s fade-to-black-and-back tween. Effort: 0.5d. Gauntlet-mode only guard matches server behavior.
- Dependency: Wait for server #491 to merge before testing end-to-end. Can develop and unit-test client logic in isolation using local bridge stub.
**#502 — Room reset client UX**
- Existing: `client/scripts/rendering/tile_renderer.gd` handles tile visual overrides. Interaction verbs rendered by UI layer.
- Deliver: (1) `reset_plate` tile type renders with amber color override. (2) 'Reset Room' verb appears when player faces reset plate. (3) On reset confirmation: 0.15s amber flash + monologue "Systems recalibrated." fires via monologue event. Effort: 0.5d.
- Integration: Server #490 (done) already emits the reset event. Client just needs to consume it.
**#503 — Auto-checklist progress tracking**
- Existing: Checklist YAML schema defined by CI #497 (done). The test client handles its own evaluation separately.
- Deliver: Client evaluates `ObserverSnapshot` against loaded checklist YAML conditions, auto-tracks verification items with a Gauntlet-mode HUD overlay. Full tracking in test-client-only mode; lightweight overlay in Godot per R2-OQ-06. Effort: 1.5d.
- Integration: Test-client-only overlay — does not affect production game HUD. Coordinate with Hoshe on checklist condition format.
**#517 — Michroma font**
- Existing: Default Godot font throughout client UI. No font assets committed yet.
- Deliver: Download Michroma from Google Fonts (OFL license), commit to `client/assets/fonts/`. Update all Label, RichTextLabel, and LineEdit nodes to use Michroma. Acceptance: font renders legibly in HUD, dialogue box, and debug overlay at game resolution. Effort: 0.5d. Purely cosmetic — no logic changes.
- Non-obvious: Michroma is geometric sans-serif at display weights — confirm legibility at small sizes (sub-16px tooltip text) before committing globally. Consider a size floor.
**#526 — Mouse-relative facing and movement**
- Existing: `client/scripts/main.gd` handles input. WASD currently uses fixed cardinal directions. `client/scripts/protocol/protocol.gd` sends facing octant to server.
- Deliver: Mouse position determines facing direction (client-side float computed from player screen position to cursor). WASD movement becomes mouse-relative (forward = toward cursor). Server receives facing octant only, unchanged interface. Effort: 1d.
- Key decision: D-054 — mouse facing is purely client-side. Server only ever sees the octant. The full float stays in the client for rendering purposes.
**#518 — Wire insert open/close to PauseSimulation**
- Existing: Server pause system at 50%/0% tick rate exists (#406, done). `client/scripts/autoloads/sim_bridge.gd` handles server commands. Insert UI open/close events are in `client/scripts/main.gd`.
- Deliver: When insert UI opens, client sends `PauseSimulation` command. When it closes, client sends `ResumeSimulation`. D-058 confirms auto-pause in SP mode. Ticket #448 was cancelled before client wiring was completed. Effort: 0.5d.
- Non-obvious: The pause/resume must be idempotent — if the server is already paused (e.g. Gauntlet room interlude), opening the insert should not double-pause.
**#521 — Confrontation D-033 color shift**
- Existing: `client/scripts/rendering/entity_renderer.gd` applies D-033 relationship colors. `ObserverSnapshot` carries entity relationship state.
- Deliver: On confrontation delivery (server #520 decrements relationship state), entity renderer fades the entity's D-033 tint toward the new relationship color using a 0.5-1s tween. Not an instant flip — a fade that the player perceives as "something changed."
- Dependency: Blocked by server #520. Start after #520 merges.
---
## Dependency Chain
```
#502 (room reset UX) — standalone (server #490 done)
#503 (auto-checklist) — standalone (CI #497 done)
#517 (Michroma font) — standalone, parallel
#518 (insert pause wire) — standalone, parallel
#526 (mouse-relative facing) — standalone, parallel
Server #491 (hub teleport) → #501 (hub teleport UX)
Server #520 (confrontation response) → #521 (D-033 color shift)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 10 prove — client" --description "body" --base main --head client
```
+167
View File
@@ -0,0 +1,167 @@
# Sprint 10: Prove — Joint / Integration
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Agents:** All implementation agents (Dudley, Stig, Hoshe, Tyre, Justine, Inigo)
---
## Pre-Sprint Decisions
No new decisions are required before Sprint 10 begins. All sprint tickets operate within confirmed decisions. Items to monitor:
| Item | Status | Needed by |
|------|--------|-----------|
| Relationship state in ObserverSnapshot | Confirm with server team that relationship state is carried per entity in the snapshot (Q-019 partial resolution) | #521 (D-033 color shift, client) |
| CI pipeline greenlight | Team Leader must confirm before #510 starts | #510 (Gitea Actions YAML, CI) |
| Server #491 merge timing | Client #501 is blocked on it — server should prioritize #491 in the first half of sprint | #501 (hub teleport UX, client) |
| Server #520 merge timing | Client #521 is blocked on it — server should ship #520 before sprint midpoint | #521 (confrontation color shift, client) |
---
## Cross-Team Dependencies
```
Server #491 (hub teleport action)
→ Client #501 (hub teleport UX — Home key + fade)
Server #520 (confrontation world response)
→ Client #521 (D-033 color shift on confrontation)
Server #500 (content scaling test)
→ CI #499 (perf baseline — benefits from #500 data)
Server #498 (Gauntlet rooms 4-7)
#506 (cross-room transitions, deferred to Sprint 11)
Server #483 (replay loading)
→ Scripted Gauntlet testing (unblocks Sprint 11 automation)
Audio #327 (8 ambient + SFX assets)
→ Client AudioManager (automatic registration via directory scan — no client code change)
→ D-073 zone crossfade stub can be tested with real assets for the first time
```
**Critical path:** `Server #491 → Client #501` and `Server #520 → Client #521`. Both server tickets need to merge before sprint midpoint to give client time to complete their dependent work.
---
## Sprint Completion Proof
Sprint 10 is complete when all of the following are observable:
**Hub teleport**
- Pressing Home key in Gauntlet mode sends `TeleportToHub`, player entity moves to hub spawn tile
- 0.3s fade-to-black-and-back plays on teleport
- Dialogue/monologue/interaction buffer is cleared on teleport
- Non-Gauntlet maps: action is rejected server-side with log warning; client shows no effect
**Room reset UX**
- `reset_plate` tile type renders with amber color
- 'Reset Room' verb appears in interaction menu when player faces reset plate
- On reset: 0.15s amber flash + "Systems recalibrated." monologue line plays
**Auto-checklist tracking**
- In test-client mode: checklist YAML loads, client evaluates ObserverSnapshot against conditions, verified items tick off automatically in HUD overlay
- Checklist HUD is absent in non-test-client Godot mode
**Gauntlet expansion**
- Server boots with Gauntlet content and all 7 rooms (existing 3 + new 4) load without panic
- Room constants for all 4 new rooms in `server/src/test_world/constants.rs`
- Room reset works correctly in new rooms (entity positions and fog reset to tick-0 state)
**Content scaling**
- `make test-server` includes the content scaling test: baseline run passes, extra-NPC comparative run stays within D-026 tick budget
**Performance baseline**
- `make perf-baseline` runs without error and produces `tests/perf/baseline.json` (or equivalent)
- Baseline file committed to repo
**Replay loading**
- `tooling/test-client --replay <file.jsonl>` loads the file and sends PlayerInput at correct tick timing against a live server
**Client UX polish**
- Michroma font renders in all Labels/RichTextLabels in HUD and dialogue box
- WASD movement is mouse-relative (forward = toward cursor direction)
- Insert open sends `PauseSimulation`, insert close sends `ResumeSimulation`
**Confrontation system**
- Confrontation delivery: target NPC shifts to Tier 2 animation, relationship state decrements, monologue spike fires (server)
- Client: entity D-033 tint fades to new relationship color over 0.5-1s on confrontation delivery
**Audio**
- 8 OGG files present in `audio` branch under `client/audio/`
- `AudioManager` registers all 8 at startup (log output confirms count)
- Ambient loops play without audible seam
- Monologue chimes have no reverb (insert-tech aesthetic per D-074)
**CI (if greenlit)**
- Gitea Actions YAML committed to `.gitea/workflows/`
- Commit tier runs in <2min on push
- PR merge gate wraps `make pre-pr` in <15min
---
## Test Plan Alignment (D-030)
| D-030 Phase | Coverage | Sprint 10 Status |
|-------------|----------|-----------------|
| Phase 1 (infra) | Done Sprints 1-2 | — |
| Phase 2 (integration) | Done Sprints 3-5 | — |
| Phase 3 (CauseChain, divergent snapshots) | Gauntlet golden suite (#485, Sprint 9) | Expanding with rooms 4-7 |
| Replay / scripted testing | #483 (test client replay loading) | In scope |
| Performance regression baseline | #499 (make perf-baseline) | In scope |
| Content scaling | #500 (baseline + NPC pack comparative) | In scope |
| Pre-PR gate | `make pre-pr` (Sprint 9, done) | Stable |
---
## Open Questions to Resolve Early
- **Q-019 (partial): Client-side entity StableId / relationship state in snapshot** — Client #521 needs relationship state per entity in ObserverSnapshot. Server team should confirm scope before #521 starts. No formal Q-NNN resolution required — just a server team confirmation.
- **CI pipeline greenlight (no Q-NNN)**#510 has an explicit "deferred until lead greenlights" note. Team Leader to confirm before CI team starts that ticket.
---
## Sequence Recommendation
**Days 1-3 (all teams in parallel):**
- Server: start #491 immediately (no blockers). Priority — unblocks client #501.
- Server: start #483 (no blockers). `replay.rs` stub exists in `tooling/test-client/src/`.
- Server: start #498 (no blockers). 4 new Gauntlet rooms; follow existing room pattern.
- Server: start #514 (small, standalone).
- Server: start #519 (standalone, #427 done).
- Client: start #502 (no blockers, #490 done).
- Client: start #503 (no blockers, #497 done).
- Client: start #517 (standalone).
- Client: start #526 (standalone).
- Client: start #518 (standalone).
- CI: start #499 (no blockers, #487 done).
- CI: start #515 (standalone, #464 done).
- Audio: start #327.
**Days 4-6:**
- Server: start #520 (no blockers). Priority — unblocks client #521.
- Server: start #500 (no blockers, #489 done).
- Client: #501 unblocks once server #491 merges.
**Days 7-10:**
- Client: #521 unblocks once server #520 merges.
- CI: #510 starts only after lead greenlight (may be later in sprint or deferred).
- All teams: verification against Sprint Completion Proof criteria.
---
## PR Workflow
Each team submits their own PR from their branch. Integration is via merge to `main`. No joint branch — all tickets are single-team. The `joint.md` tracks integration readiness but does not produce a separate branch.
```bash
# Verify sprint assignment before PR
db/connectors/sprint status --team <team>
# Standard PR (all flags required — see CLAUDE.md)
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(<scope>): sprint 10 prove — <team summary>" \
--description "body" --base main --head <branch>
```
+103
View File
@@ -0,0 +1,103 @@
# Sprint 10: Prove — Server Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (arch review), Hoshe (QA)
---
## Carry-over from Sprint 9
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #491 | Hub teleport action (PlayerAction::TeleportToHub) | backlog | Targeted for Sprint 9, slipped due to #487 dependency completing late. Unblocked as of sprint close. |
| #483 | Test client replay loading + tick-scheduled sending | backlog | Critical for scripted Gauntlet testing. Targeted Sprint 9, slipped. Unblocked (#480 done). |
| #500 | Content scaling test (baseline + extra NPC comparative) | backlog | Targeted Sprint 9, deferred. Unblocked (#489 done). |
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #491 | Hub teleport action (PlayerAction::TeleportToHub) | #487 (done) |
| #483 | Test client replay loading + tick-scheduled sending | #480 (done) |
| #498 | Gauntlet rooms 4-7 (Interaction Gallery, Fog Theater, Crowd Plaza, Dialogue Room) | #487 (done) |
| #500 | Content scaling test (baseline + extra NPC comparative) | #489 (done) |
| #514 | blocked_entities debug field on ObserverSnapshot | — |
| #519 | Walk-away NPC reaction — server-side Phase 2 (animation shift, routine change) | #427 (done) |
| #520 | Confrontation world response — server-side trigger | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-010 (deterministic simulation), D-026 (100ms tick budget), D-041 (knowledge graph data model)
- `decisions/perception.md` — D-059 (fog/shader five layers), D-060 (cognitive delay), D-063 (confrontation world response), D-064 (walk-away three phases)
- `decisions/scope.md` — D-038 (audio in v0.1 scope)
---
## Notes
**#491 — Hub teleport action**
- Existing: `server/src/test_world/rooms/hub.rs` for the hub spawn location. `PlayerAction` enum lives in `server/src/bridge/`.
- Deliver: `PlayerAction::TeleportToHub` variant. On receipt: move player entity to hub spawn tile, clear dialogue/monologue/interaction buffer. Does NOT affect room state, inventory, game time, or knowledge graph. Reject the action with a log warning on non-Gauntlet maps.
- Integration: Client #501 depends on this — ship it first half of sprint.
**#483 — Test client replay loading**
- Existing: `tooling/test-client/src/` has `main.rs`, `golden.rs`, `replay.rs` (stub). The `replay.rs` module exists but the loading logic was deferred.
- Deliver: Load a JSONL replay file (one JSON array per tick), send `PlayerInput` at correct tick timing. Target: `tooling/test-client/src/replay.rs`.
- Integration: Enables scripted Gauntlet testing end-to-end — unblocks the cross-room test scenarios in later sprints.
**#498 — Gauntlet rooms 4-7**
- Existing: `server/src/test_world/rooms/` has `hub.rs`, `inventory_warehouse.rs`, `occlusion_corridor.rs`, `pause_chamber.rs`. The `mod.rs` wires them together.
- Deliver: Four new rooms — Interaction Gallery (24x20, 5 entities, D-057), Fog Theater (44x32, 4 entities, D-059), Crowd Plaza (32x32, 15 entities), Dialogue Room (28x20, 4 entities, D-041). Follow the pattern of existing room modules. Each room must have constants in `server/src/test_world/constants.rs`.
- Integration: Unblocks #506 (cross-room transition tests) in a future sprint.
**#500 — Content scaling test**
- Existing: The baseline test infrastructure is in place post-#489. Content packs are in `server/src/content/`.
- Deliver: A test that runs the baseline NPC pack then the same scenario with additional NPCs, asserts tick timing stays within D-026 budget and behavior is unchanged for original NPCs.
- Integration: Feeds into CI perf baseline (#499) which CI team handles.
**#514 — blocked_entities debug field**
- Existing: `ObserverSnapshot` in `server/src/bridge/`. LOS computation is in `server/src/perception/`.
- Deliver: Add `blocked_entities: Vec<StableId>` (or `Option<Vec<StableId>>`) to `ObserverSnapshot`. Feasibility confirmed at ~300 tile lookups/tick (R2-OQ-05). Debug-only field; `#[serde(default)]` so client gracefully ignores on older snapshots.
- Integration: Improves test client debug output. Standalone, no cross-team dependency.
**#519 — Walk-away NPC reaction Phase 2**
- Existing: Phase 1 (client fade, #437) and Phase 3 (KG recording, #427) are done. Phase 2 is the missing server-side piece.
- Deliver: On `PlayerAction::WalkAway` (or dialogue exit event), server triggers: (1) target NPC shifts animation tier to Tier 2 (D-047), (2) NPC routine deviation is recorded in their state machine. D-064 Phase 2 scope.
- Integration: Standalone server-side work. No client changes required.
**#520 — Confrontation world response**
- Existing: Confrontation delivery is wired through dialogue system. D-063 specifies the world response.
- Deliver: On confrontation delivery: (1) target NPC shifts to Tier 2 animation (D-047 animated tier), (2) NPC relationship state decremented (affects D-033 color which client #521 renders), (3) monologue spike event emitted. Three separate effects, all server-authoritative.
- Integration: Client #521 blocked by this — complete #520 first half of sprint.
---
## Dependency Chain
```
#483 (replay loading) — standalone, parallel
#491 (hub teleport server) → #501 (hub teleport client UX, cross-team)
#498 (Gauntlet rooms 4-7) → #506 (cross-room transitions, future sprint)
#500 (content scaling) → #499 (perf baseline, CI team)
#514 (blocked_entities) — standalone, parallel
#519 (walk-away phase 2) — standalone, parallel
#520 (confrontation world response) → #521 (D-033 color shift, client team)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 10 prove — server" --description "body" --base main --head server
```
+80
View File
@@ -0,0 +1,80 @@
# Sprint 11: Combine — Client Tasks
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Branch:** `client`
**Agents:** Stig (UI/client dev), Tyre (arch review), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #507 | WRONG button full captures (input history ring buffer, snapshot history, replay seed) | #495 (done) |
| #522 | Resolve OQ-07: no-insert interaction behavior (diegetic test) | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot/Rust IPC, ObserverSnapshot as the only data crossing the boundary), D-030 (testability architecture — JSON output format for bug reports)
- `decisions/perception.md` — D-056 (cursor states — insert-styled geometric, diegetic test: labels render on z-layer 6, disappear if insert is off), D-057 (entity interaction — vertical list, diegetic test referenced), D-048 (neural insert overlay — visual design)
---
## Open Questions to Resolve Early
- **OQ-07 (#522): No-insert interaction behavior** — Resolve early (day 1-2) because the answer determines whether #507's WRONG button output includes cursor-state data in the capture, and whether the entity interaction system (#432) needs a code path for insert-off mode. Three candidate resolutions: (a) cursor reverts to default shape only, no verb labels; (b) cursor does not change at all; (c) non-insert fallback renders prompts via alternative channel. Resolve, document as a decision amendment to D-056 or D-057, then implement.
---
## Notes
**#507 — WRONG button full captures (input history ring buffer, snapshot history, replay seed)**
- Existing: The WRONG button MVP (#495, done Sprint 9) is in place as a `bug_report.gd` autoload. F12 pauses, captures a single `ObserverSnapshot` (JSON), runs the text renderer, saves tick/room/seed and a tester description to `tests/bug-reports/gauntlet-t{tick}-{timestamp}/`. The MVP is a single-tick point-in-time capture.
- Deliver: Upgrade the bug capture system to a rolling 60-tick history:
- **Input history ring buffer:** Maintain a 60-entry circular buffer of `PlayerInput` arrays in `bug_report.gd`. Every tick, push the current tick's input(s) onto the buffer (oldest entry evicted when full). On F12 capture, flush the last 60 ticks of inputs to `inputs.jsonl` (one JSON array per line — matches the replay format from `tooling/test-client/src/replay.rs`).
- **Snapshot history:** Maintain a parallel 60-entry circular buffer of `ObserverSnapshot` JSON strings. Flush to `snapshots.jsonl` on capture (one snapshot per line). Allows replay of the exact 60-tick window leading to the bug.
- **Replay seed:** Include the server's current RNG seed in the capture output (requires the server to send the seed in the `ObserverSnapshot` or via a metadata message). Output as `seed.txt` in the bug report directory.
- **Room metadata:** Already present in MVP — confirm it includes the room name and map identifier.
- Output directory format is unchanged: `tests/bug-reports/gauntlet-t{tick}-{timestamp}/`.
- Integration: The `inputs.jsonl` output must be valid input for `tooling/test-client --replay <file>` (Sprint 10 #483). Test this by replaying a captured session against a fresh server — it should reproduce the same snapshot sequence.
- The ring buffer must add negligible per-tick cost — pre-allocate the 60-slot arrays at startup. Do not allocate on every tick.
- Non-obvious: The server RNG seed may need a new field in `ObserverSnapshot` or a dedicated handshake message. Coordinate with the server team if a protocol change is needed. If the seed is already present in an existing field, document which field.
**#522 — Resolve OQ-07: no-insert interaction behavior (diegetic test)**
- Existing: D-056 cursor states and D-057 entity interaction both reference the diegetic test: interaction labels render on z-layer 6 (insert overlay) and disappear if the insert is off. `client/scripts/rendering/cursor_renderer.gd` handles cursor state transitions. The entity interaction vertical list is in `client/scripts/rendering/` (likely `entity_renderer.gd` or a dedicated interaction UI file, per #432).
- Deliver: Resolve OQ-07 with one of three options, implement, and document:
- **(a) Cursor reverts to default shape only, no verb labels** — cursor state machine still fires (shape changes: default → entity hover bracket or X-shape on object hover), but the tooltip/verb label layer is suppressed because z-layer 6 (insert overlay) has no data to render. This is the most diegetically consistent option: the character still physically orients to the target, but receives no information from their insert.
- **(b) Cursor does not change at all** — insert-off mode means the cursor_renderer.gd stays in Default state regardless of what the cursor is over. Full suppression.
- **(c) Non-insert fallback** — prompts appear via a non-insert visual channel (e.g., world-space text at z-layer 2 or 3). Least diegetically consistent; least likely to be the right answer given D-056's "diegetic test" framing.
- After resolution: amend the relevant decision (D-056 or D-057) with the OQ-07 resolution note, and update `client/scripts/rendering/cursor_renderer.gd` and the interaction list renderer to enforce the chosen behavior when `insert_active == false`.
- This is primarily a design resolution + small implementation task. Effort: 0.5d for decision, up to 0.5d for code.
---
## Dependency Chain
```
#507 (WRONG button full captures) — standalone (#495 done), parallel
#522 (OQ-07 resolution) — standalone, resolve day 1-2
```
Both tickets are independent. No cross-team blockers. Client can run both tracks in parallel from day 1.
**Watch:** If #507 requires a new field on `ObserverSnapshot` for the RNG seed, that is a server protocol change. Raise with the server team immediately — do not wait until the ticket is otherwise complete.
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): sprint 11 combine — client" \
--description "body" --base main --head client
```
+137
View File
@@ -0,0 +1,137 @@
# Sprint 11: Combine — Joint / Integration
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Agents:** Dudley, Tyre, Hoshe (server); Stig, Tyre, Hoshe (client)
---
## Pre-Sprint Decisions
| Item | Status | Needed by |
|------|--------|-----------|
| OQ-18 resolution (#524) | Resolve day 1-2. Output: new D-NNN decision in `decisions/`. May require update to dialogue filter in #305. | Downstream content work (unblocked immediately on resolution) |
| OQ-07 resolution (#522) | Resolve day 1-2. Output: D-056 or D-057 amendment. | #507 (WRONG button — cursor state data in captures) |
| RNG seed in ObserverSnapshot | Client team must confirm whether seed is already present in the snapshot wire format. If not, server team must add it. Raise on day 1. | #507 (replay seed capture) |
No other decisions are required before Sprint 11 begins. All other tickets operate within confirmed decisions.
---
## Cross-Team Dependencies
```
Server #504 (Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
→ Server #506 (cross-room transitions T1-T8)
Client #507 (WRONG button full captures)
→ potential Server protocol change (RNG seed field on ObserverSnapshot)
raise day 1 if needed; server team to respond same day
Server #524 (OQ-18 resolution)
→ no client code change, but unblocks copy team content work
Client #522 (OQ-07 resolution)
→ cursor_renderer.gd + interaction list renderer update (client-only)
```
**Critical path:** `Server #504 → Server #506`. Server team should start #504 immediately. All other tickets are independent parallel tracks.
**Potential protocol dependency:** If #507 requires adding a seed field to `ObserverSnapshot`, this is a server change that the client depends on. Identify this on day 1 and route it through the server team as a minimal, additive struct field with `#[serde(default)]` so the client handles older snapshots gracefully during development.
---
## Sprint Completion Proof
Sprint 11 is complete when all of the following are observable:
**Gauntlet expansion (server #504)**
- Server boots with Gauntlet content and all 10 rooms load without panic (7 from Sprint 10 + 3 new: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
- Room constants for all 3 new rooms in `server/src/test_world/constants.rs`
- Sprint Gauntlet: player sprints past an NPC → interaction buffer empty during sprint, anomaly monologue fires retroactively post-sprint
- Eavesdrop Alcove: player in Careful stance at eavesdrop position → World SFX boost observable in log, conversation murmur event emitted
- Confrontation Stage: player confronts NPC → peripheral NPC movement during confrontation does NOT trigger anomaly monologue; delayed "did I miss something?" monologue fires post-confrontation
**Cross-room transitions (server #506)**
- `make test-server` includes 8 cross-room transition tests (T1-T8)
- All 8 pass: knowledge state, inventory, fog carry-over, and stance all survive room transitions correctly
- T2 (Inventory Interact): item `CarriedBy` survives transition; item absent from tile queries in new room; present in player inventory in snapshot
**Content scaling (server #513)**
- `make test-server` includes the 100-tick max-NPC stress test
- Test passes: every tick within D-026 100ms budget at maximum Active tier entity count (80 NPCs)
- Behavioral regression check passes: baseline NPC subset matches `tests/perf/baseline.json` at tick 100
**Contraband detection (server #425)**
- NPC with `ScanAuthority` within interaction range of player carrying a `Contraband`-flagged item → NPC KG updated with `HasContraband` fact at `KnowsDetails` confidence
- Scan event present in ObserverSnapshot for that tick
- No scan event emitted when player carries no contraband items
- BTreeMap used for KG storage (D-010 determinism, D-041)
**OQ-18 resolved (server #524)**
- New D-NNN decision committed to `decisions/` (content.md or architecture.md as appropriate)
- Decision states clearly whether dialogue access tiers and D-041 confidence tiers are the same system or layered
- If #305 dialogue filter changed: `make test-server` still passes
**WRONG button full captures (client #507)**
- F12 during Gauntlet play → bug report directory contains: `inputs.jsonl` (60 ticks of PlayerInput history), `snapshots.jsonl` (60 ticks of ObserverSnapshot history), `seed.txt` (server RNG seed), plus existing MVP files (text render, description)
- `tooling/test-client --replay inputs.jsonl` against a fresh server with matching seed reproduces the same snapshot sequence
- Ring buffer pre-allocated at startup — no per-tick allocation in hot path
**OQ-07 resolved (client #522)**
- D-056 or D-057 amended with OQ-07 resolution note
- `client/scripts/rendering/cursor_renderer.gd` enforces the chosen insert-off behavior
- Interaction verb labels absent (or reduced per chosen option) when insert is off in Gauntlet test mode
---
## Test Plan Alignment (D-030)
| D-030 Phase | Coverage | Sprint 11 Status |
|-------------|----------|-----------------|
| Phase 1 (infra) | Done Sprints 1-2 | — |
| Phase 2 (integration) | Done Sprints 3-5 | — |
| Phase 3 (CauseChain, divergent snapshots) | Gauntlet golden suite expanded Sprint 9-10 | Expanding with 3 new rooms (#504) |
| Cross-room transitions | T1-T8 from Gestalt (#506) | In scope this sprint |
| Replay / scripted testing | Replay loading done (#483, Sprint 10) | WRONG button ring buffer feeds replay system (#507) |
| Performance regression | Baseline committed (#499, Sprint 10) | 100-tick stress test adds to baseline (#513) |
| Pre-PR gate | `make pre-pr` stable | Unchanged |
---
## Sequence Recommendation
**Days 1-2 (all teams, parallel):**
- Server: start #504 immediately (no blockers). Sprint Gauntlet is the critical path node.
- Server: start #524 (OQ-18 resolution) on day 1. Write decision by end of day 2.
- Server: start #513 (stress test) in parallel — standalone, no dependency on #504.
- Server: start #425 (contraband detection) in parallel — standalone.
- Client: start #522 (OQ-07 resolution) on day 1. Implement and amend decision by end of day 2.
- Client: start #507 (WRONG button) on day 1 — confirm RNG seed availability first.
**Days 3-5:**
- Server: complete all 3 rooms in #504 and verify server boots without panic. Hand off to #506.
- Server: start #506 (cross-room transitions) once #504 is merged.
- Client: #507 ring buffer implementation complete; end-to-end replay test.
**Days 6-10:**
- Server: complete #506 (T1-T8 tests pass).
- All teams: verify against Sprint Completion Proof criteria.
---
## PR Workflow
Each team submits their own PR from their branch. Integration is via merge to `main`. No joint branch.
```bash
# Server
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): sprint 11 combine — server" \
--description "body" --base main --head server
# Client
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): sprint 11 combine — client" \
--description "body" --base main --head client
```
+103
View File
@@ -0,0 +1,103 @@
# Sprint 11: Combine — Server Tasks
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (arch review), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #504 | Gauntlet rooms: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage | #487 (done) |
| #506 | Cross-room transition test scenarios (T1-T8 from Gestalt) | #498 (done), #504 |
| #513 | Content scaling stress test (100 ticks, max-NPC pack, tick budget) | #487 (done), #499 (done) |
| #425 | Contraband detection — NPC scan checks carried items + KG | #424 (done) |
| #524 | Resolve OQ-18: dialogue access tiers vs D-041 confidence hierarchy | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-010 (deterministic simulation), D-026 (100ms tick budget), D-041 (knowledge graph — 4-level confidence hierarchy, BTreeMap requirement, dialogue tier mapping)
- `decisions/perception.md` — D-055 (sprint suppresses interaction buffer — Sprint Gauntlet room), D-059 (fog/shader five layers — Eavesdrop Alcove), D-060 (cognitive delay — Eavesdrop Alcove), D-070 (confrontation as cognitive vulnerability — Confrontation Stage)
- `decisions/content.md` — D-028 (dialogue access layers — OQ-18 resolution), D-065 (contraband spec)
- `decisions/scope.md` — D-065 (contraband detection scope)
---
## Open Questions to Resolve Early
- **OQ-18 (#524): Dialogue access tiers vs D-041 confidence hierarchy** — Resolve in the first half of the sprint. The decision affects #305 (dialogue selection pipeline filtering logic) which may need updating. Document result as a new D-NNN decision. Consult Gestalt and Nigel on the archetype dimension before closing.
---
## Notes
**#504 — Gauntlet rooms: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage**
- Existing: `server/src/test_world/rooms/` has 7 rooms: `hub.rs`, `fog_theater.rs`, `occlusion_corridor.rs`, `inventory_warehouse.rs`, `interaction_gallery.rs`, `pause_chamber.rs`, `dialogue_room.rs`, `crowd_plaza.rs`. Constants live in `server/src/test_world/constants.rs`. The `ROOMS` array there is additive-only — append new entries at the end or StableId ranges will shift and break all existing golden files.
- Deliver three new room modules, each following the exact pattern of existing rooms:
- **Sprint Gauntlet** (~28x20): Tests D-055 sprint suppression. Player sprints past a visible NPC; interaction buffer must be empty during sprint, anomaly monologue must fire retroactively after sprint ends. Include a sprint-trigger tile and a reset plate.
- **Eavesdrop Alcove** (~24x16): Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful stance at a corner within audible range of two NPCs in conversation. Test: World SFX boost observable, conversation murmur event emitted (D-072), interaction buffer suppressed at eavesdrop distance.
- **Confrontation Stage** (~32x24): Tests D-070 confrontation vulnerability. Player confronts an NPC target; peripheral NPC movement that occurs during confrontation should not trigger anomaly monologue (suppressed by confrontation dip). Post-confrontation delayed monologue fires for the missed event.
- Each room needs constants in `server/src/test_world/constants.rs` (GauntletRoom struct + StableId range). Append after `RESET_PLATE_STABLE_IDS`. Update `ROOMS` array and `EXPECTED_ENTITY_COUNT`.
- Blocks #506 — complete #504 before the sprint midpoint.
**#506 — Cross-room transition test scenarios (T1-T8 from Gestalt)**
- Existing: `tooling/test-client/src/replay.rs` is complete (Sprint 10 #483). `tooling/test-client/src/golden.rs` has snapshot diffing infrastructure. All 7 Sprint 10 rooms (#498) are done; all 3 new Sprint 11 rooms (#504) must be done first.
- Deliver 8 cross-room transition tests covering system combination bugs at room boundaries. Source: `gestalt-round3.md` scenarios T1-T8. Approximate scope:
- **T1 Sprint Exit:** Player sprints out of Sprint Gauntlet room into corridor, asserts interaction buffer clears on entry, restores on exit.
- **T2 Inventory Interact:** Player picks up item in Inventory Warehouse, carries it to adjacent room, verifies CarriedBy survives room transition (tile presence removed, item appears in player inventory in new room snapshot).
- **T3 Pause Anywhere:** Player pauses mid-corridor (between rooms), asserts tick stops, resumes cleanly.
- **T4-T8:** Refer to `gestalt-round3.md` for full scenario specs (knowledge state persistence, fog carry-over, eavesdrop cut on transition, confrontation interrupt on room change).
- Write tests in `server/tests/` or as a dedicated integration test file. Tests must use the replay system or `cargo nextest` ECS world setup — no ad-hoc test harness.
- Blocked by #504. Start after Sprint Gauntlet, Eavesdrop Alcove, and Confrontation Stage are wired and the server boots without panic.
**#513 — Content scaling stress test (100 ticks, max-NPC pack, tick budget)**
- Existing: `server/src/content/` has the content loader. `tooling/perf-baseline` script exists (Sprint 10 #499). The baseline NPC pack is in place. `tests/perf/baseline.json` committed.
- Deliver: A test that spawns the maximum entity count (Active tier ceiling per D-026: 80 NPCs), runs 100 ticks, and asserts that each tick completes within the 100ms budget. Also asserts no behavioral regression on the baseline NPC subset (same positions and knowledge state at tick 100 as in the baseline run). Run via `make test-server` — not a separate script.
- Integration: Compare output against `tests/perf/baseline.json`. The perf baseline tool (#499, done) already defines the output format; match it.
- Standalone, no cross-team dependency. Can run in parallel with #504.
**#425 — Contraband detection: NPC scan checks carried items + KG**
- Existing: `server/src/simulation/inventory.rs` has `CarriedBy`, `InventorySlot`, `ItemName`, and `collect_inventory_for()`. The inventory system is complete (#424 done). D-065 defines contraband as unlicensed lattice components. `server/src/knowledge/` has `graph.rs`, `registry.rs`, `types.rs`.
- Deliver: An NPC scan system that, when an NPC with scan authority is within interaction range of the player, queries the player's carried items for contraband flag. If a contraband item is in inventory, update the NPC's KnowledgeGraph (`KnowsDetails`, source `DirectObservation`) for the player entity with fact: `HasContraband`. Emit a scan event to the ObserverSnapshot for client-side rendering (no client ticket required — use existing event wire).
- Specifics: Define a `Contraband` marker component on item entities. Define `ScanAuthority` component on NPC entities with scan permissions. The scan check runs in a dedicated system scheduled after the perception phase, before the snapshot phase. Use `StableId` references throughout — no raw bevy Entity handles in the knowledge entry.
- Standalone. No cross-team dependency.
**#524 — Resolve OQ-18: dialogue access tiers vs D-041 confidence hierarchy**
- Existing: D-028 defines 4 dialogue access layers: surface/real/secret/unprompted. D-041 defines 4 KG confidence tiers: `Suspects < KnowsOf < KnowsDetails < Direct`. The dialogue selection pipeline (#305) already filters by confidence tier. OQ-18 asks whether character-archetype dialogue access tiers are the same system or a layered system on top.
- Deliver: A written decision (commit to `decisions/content.md` or `decisions/architecture.md` as appropriate as a new D-NNN) resolving one of: (a) same system — archetype access tiers map directly 1:1 to KG confidence tiers (surface=Suspects+, real=KnowsOf+, secret=KnowsDetails+), or (b) layered — character archetype adds a second gating dimension independent of KG confidence (e.g., detective can always access surface regardless of KG confidence because institutional authority, smuggler needs KnowsOf minimum for surface because outsider). If the decision changes the filtering logic in #305, update that system accordingly.
- This is a design resolution ticket, not a large implementation task. The output is a decision document + any follow-on code change to the dialogue filter. Time estimate: 0.5d for discussion + decision write-up, up to 1d if #305 needs updating.
- Resolve early — day 1 or 2 — so downstream content work can proceed with confidence.
---
## Dependency Chain
```
#504 (Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
#506 (cross-room transitions T1-T8)
#513 (stress test 100 ticks) — standalone, parallel
#425 (contraband detection) — standalone, parallel
#524 (OQ-18 resolution) — standalone, resolve day 1-2
```
**Critical path:** `#504 → #506`. Complete #504 before sprint midpoint. All other tickets are parallel tracks.
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): sprint 11 combine — server" \
--description "body" --base main --head server
```

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