Compare commits

..
122 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 2df7cc2153 chore(meta): release v0.1.9
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:02:41 +01:00
jpmschweitzerandClaude Opus 4.6 3ca12e33a3 feat(skills): start-sprint marks actionable tickets in_progress
Step 7 now transitions all unblocked sprint tickets to in_progress
immediately rather than deferring to agents (who forget). Completes
the ticket lifecycle: start-sprint → in_progress, push-pr → review,
sprint stop → done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:59:06 +01:00
jpmschweitzerandClaude Opus 4.6 5f3514b3c4 feat(skills): push-pr marks referenced tickets as review
Step 7 scans commit messages for #NNN ticket refs and transitions
in_progress tickets to review status. Only in_progress → review;
other statuses are left alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:57:39 +01:00
jpmschweitzerandClaude Opus 4.6 9728eb5d1d feat(db): auto-close picked-up tickets on sprint stop
sprint stop now marks in_progress/review tickets as done
automatically — agents often forget to update status after merging.
Backlog/ready tickets are left as carry-over candidates since they
were never started.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:56:46 +01:00
jpmschweitzerandClaude Opus 4.6 a91f042d2d feat(skills): add sprint close + version bump workflow to start-sprint
When invoked on main, /start-sprint now closes the active sprint,
bumps the version to v0.1.N, moves changelog entries under the new
version heading, tags, and pushes. Team branch behavior unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:55:07 +01:00
jpmschweitzerandClaude Opus 4.6 9cd6969db1 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:58 +01:00
jpmschweitzerandClaude Opus 4.6 d627c2bc0a chore(client): add missing .uid and .import files from Godot 4.6
Generated by headless --import after audio merge. These are needed
for stable UID references across editor sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:41 +01:00
jpmschweitzerandClaude Opus 4.6 843dbce1ba feat(db): add --description flag to ticket create CLI
ticket create previously had no way to set descriptions, causing
agents to work around with raw SQL or mangled titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:33 +01:00
jpmschweitzerandClaude Opus 4.6 4d44863552 fix(client): make game depends on full build, use --import for client
build-client used --quit without --import, which failed to create
.godot/ and register class_name types after a clean. game target
only depended on build-server, skipping client entirely. clean now
preserves the .godot/ directory itself while clearing contents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:27 +01:00
jpmschweitzerandClaude Opus 4.6 61b5ca7178 chore(db): backup after audio merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:21:00 +01:00
jpmschweitzer fdf82a89ff Merge remote-tracking branch 'origin/audio' 2026-02-18 11:20:16 +01:00
jpmschweitzerandClaude Opus 4.6 0adfb15bc9 chore(db): backup after client PR #34 merge
Tickets #495, #496 marked done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:14:22 +01:00
jpmschweitzer 5a4156c967 Merge remote-tracking branch 'origin/client' 2026-02-18 11:12:42 +01:00
jpmschweitzerandClaude Opus 4.6 dd8718c762 fix(client): address PR #34 review — 8 items from Hoshe and Tyre
- Cast Variant to String via str() before passing to _on_room_change
- Clear _current_room_id on null room transition (fixes re-entry skip)
- Add push_error for failed dir creation and file writes in _save_report
- Fix docstring: tests/gauntlet-stats.json → user://dev/gauntlet-stats.json
- Namespace stats path to user://dev/ to avoid save data collision
- Replace print() with push_warning in _save_report (codebase consistency)
- Downgrade client-only wire guard from push_warning to silent return

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:06:56 +01:00
jpmschweitzerandClaude Opus 4.6 ddf7e482cd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:02:57 +01:00
jpmschweitzerandClaude Opus 4.6 408f54f686 fix(ci): address PR #33 review — golden restore safety, pre-pr gate
- golden-diff restores committed file even on cargo test failure
- Wire checklist-validate into pre-pr-content gate
- Fix schema description: condition IDs are globally unique, not
  per-file; document room_id prefix naming convention
- Add schema file missing error handling in validate-checklist
- Add scope discriminator field (per_room/cross_room) to schema
- Check pyyaml and jsonschema packages in setup-tooling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:02:24 +01:00
jpmschweitzerandClaude Opus 4.6 68848377c7 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:41 +01:00
jpmschweitzerandClaude Opus 4.6 20427b9d8b feat(audio): revise Sprint 7 UI audio assets for #453
Re-generate monologue chimes (were oversized placeholders) and
fog_recognition (was silent). Fix sample rate headers on remaining
assets (192kHz→44.1kHz). All 8 UI assets now at 44.1kHz stereo,
LUFS -16 normalized, Vorbis q6, durations within D-038 spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:19 +01:00
jpmschweitzerandClaude Opus 4.6 9e027c5e9d chore(meta): update changelog for sprint 9 client work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:42 +01:00
jpmschweitzerandClaude Opus 4.6 a292674342 feat(client): add 24 gauntlet + bug report tests (#495, #496)
Replace stub F12 tests with BugReportDialog integration tests (dialog
exists, activates on action, pause/unpause, wire guard, text render
with entities/monologue/dialogue, empty snapshot edge case). Add 16
GauntletHUD tests (format_time, visibility toggle, timer lifecycle,
room change reset, personal bests record/overwrite/preserve, null room,
timer paused when hidden, finalize, session attempts, snapshot
roundtrip). Anti-tedium assertions now falsifiable against real
GameState.room_id and gauntlet_mode properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:26 +01:00
jpmschweitzerandClaude Opus 4.6 d7755698b2 feat(client): add WRONG button F12 bug report capture (#495)
F12 pauses simulation, shows modal LineEdit prompt, saves three files
to user://bug-reports/gauntlet-t{tick}-{timestamp}/: snapshot.json
(full ObserverSnapshot), render.txt (simplified client-side text
render), description.txt (tester notes + tick/room/stance metadata).
Esc cancels without saving. Double-activation guard prevents stacking.

BUG_REPORT action added to InputMapper with wire guard in SimBridge
(client-only, never sent to server). Dialog on ModalLayer (CL 30).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:15 +01:00
jpmschweitzerandClaude Opus 4.6 bc073c891f feat(client): add gauntlet room timer + personal bests (#496)
GauntletHUD in UILayer shows TIMER: MM:SS (PB: MM:SS). Timer starts on
room entry, resets on room change, records personal bests to
user://gauntlet-stats.json. Session summary printed on disconnect.
Hidden in non-gauntlet mode (anti-tedium guard).

Adds room_id and gauntlet_mode fields to GameState, parsed from
ObserverSnapshot. Main.gd wires update_from_state() and finalize().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:05 +01:00
jpmschweitzerandClaude Opus 4.6 c4f78d8926 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:44:50 +01:00
jpmschweitzerandClaude Opus 4.6 d8d005169a feat(ci): add golden file targets and checklist schema (#486, #497)
Golden targets: make golden-diff shows color diff if simulation output
changed, make golden-update regenerates and stages for review.

Checklist schema: JSON Schema for 7 condition types evaluable from
ObserverSnapshot. Per-room YAML checklists for 3 Gauntlet rooms plus
cross-room checks. Validation script + make checklist-validate/generate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:44:33 +01:00
jpmschweitzerandClaude Opus 4.6 318a1e8b07 chore(db): snapshot database after Sprint 9 PR merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:24:25 +01:00
jpmschweitzer bb94a6581c Merge remote-tracking branch 'origin/ci' 2026-02-18 10:24:13 +01:00
jpmschweitzer ffe3fdaf3e Merge remote-tracking branch 'origin/client' 2026-02-18 10:23:58 +01:00
jpmschweitzer 445f34f22e Merge remote-tracking branch 'origin/server' 2026-02-18 10:23:33 +01:00
jpmschweitzerandClaude Opus 4.6 48802f6b48 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:13:17 +01:00
jpmschweitzerandClaude Opus 4.6 f1e5d76ad8 chore(skills): clarify verdict rules in review-pr skill
Add explicit mapping: critical/warning → REQUEST_CHANGES,
suggestion-only or clean → APPROVE.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:13:06 +01:00
jpmschweitzerandClaude Opus 4.6 4f20a996aa fix(client): address PR #30 review — 9 items from Hoshe and Tyre
- Add SimBridge._last_snapshot = null to P3 before_test() (contamination risk)
- Clarify mono.is_visible is custom property, not CanvasItem builtin
- Camera smoothing test uses approximate equality instead of exact
- Propagate inner _encode_message errors in array/dict encoding
- Add explanatory comment on int_64 encoder branch
- Document unfalsifiable gauntlet guard as intentional future-proof
- Remove loose D-053 citation from LERP_SPEED pin
- Assert FogOverlay is sibling of FogGroup under World
- Add CANVAS_MODAL=30 (ModalLayer) assertion in P3-Z04

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:46:08 +01:00
jpmschweitzerandClaude Opus 4.6 f8db996eb9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:31 +01:00
jpmschweitzerandClaude Opus 4.6 aadfe64cfb chore(meta): update changelog for PR #32 review fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:30 +01:00
jpmschweitzerandClaude Opus 4.6 09eb6d7582 refactor(simulation): address PR #32 review — 14 items from Hoshe + Tyre
Hoshe (code quality):
- Remove dead RoomMember component from reset.rs
- Remove execute_reset (dual API trap); plan_reset is sole production path
- .unwrap() → .expect() on reset_plate in setup_gauntlet boot path
- Add 10s read timeout to TCP runtime test (prevents hangs)
- Register player in EntityRegistry in runtime boot test
- Document room_at z-range and corridor overlap assumptions
- Derive entity count from EXPECTED_ENTITY_COUNT constant (was hardcoded 24)
- Add reset plate (49-51) verification to stable_id_ranges_match_spec
- Add debounce exact boundary test (tick 9 rejected, tick 10 accepted)

Tyre (architecture):
- Gate test_world rooms/constants/setup behind "gauntlet" feature (default-on);
  reset module stays always-compiled (production dependency via input system)
- Document setup_gauntlet scheduler bypass for future tracking
- Extract runtime TCP test to content_runtime.rs (separate failure modes)

507 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:17 +01:00
jpmschweitzerandClaude Opus 4.6 3ccac5432b fix(ci): address PR #31 review — harden cross-encoder fixture pipeline
- Fail on encode errors instead of silently writing empty .msgpack files
- Fail test on missing/empty fixture dir instead of silent skip
- Add all missing action variants (MoveSouth, MoveEast, MoveWest,
  Unpause, ToggleStanceDown, WalkAway) to GDScript fixture generator
- Add GDScript fixture staleness check to make pre-pr
- Validate repo root detection before writing outside client/
- Add file.flush() before close in headless mode
- Document fixture failure recovery in DEVOPS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:13 +01:00
jpmschweitzerandClaude Opus 4.6 e66352e0ea feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion
Corridor, Pause Chamber) + Central Hub, room constants module, room
reset trigger mechanism, Layer 3 subprocess integration test, golden
file comparison engine and test suite, and content runtime validation.

Tickets: #482, #484, #485, #487, #488, #489, #490

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:25:57 +01:00
jpmschweitzerandClaude Opus 4.6 24289a6ded chore(meta): update changelog for whatsinagame kit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:48 +01:00
jpmschweitzerandClaude Opus 4.6 908691bf0b chore(skills): add domain-action naming convention to create-skill
Documents the {domain}-{action} naming pattern for new skills
so future skills follow the convention established in the kit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:35 +01:00
jpmschweitzerandClaude Opus 4.6 7158ae9cf5 feat(meta): add whatsinagame multi-agent team starter kit
Claude-native starter kit that bootstraps multi-agent team
infrastructure for any project. Clone once, install as a global
skill, run /kit-install in any project directory.

Includes:
- 3-tier profile system (minimal/standard/full: 3-12 agents)
- 16 agent archetype templates with personality spectrum
- 18 skill templates using domain-action naming convention
- Stakeholder persona panel for workshops and PR reviews
- SQLite ticketing DB with CLI tools (config-based DB paths)
- Decision tracking, sprint lifecycle, workshop orchestration
- Multi-git-host support (GitHub, Gitea, GitLab)
- /kit-update skill for syncing with source repo evolution
- Naming theme support for agent identity/flavor
- Smoke tests for all three profile tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:30 +01:00
jpmschweitzerandClaude Opus 4.6 da63aa580e feat(ci): add make fixtures-client target for GDScript->Rust cross-encoder validation (#475)
Closes the bidirectional protocol compatibility loop (D-030 Layer 1):
- GDScript fixture generator (20 fixtures: inputs, boundary ticks, batch)
- Rust decoder test verifying all GDScript-encoded fixtures deserialize
- Makefile target with generation + verification in one step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:20:24 +01:00
jpmschweitzerandClaude Opus 4.6 1254fa1b28 chore(meta): update changelog for sprint 9 client work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 4648ce62bb feat(client): add 5 anti-tedium regression tests (#494)
Regression guards for Sprint 9 QA features:
- F12 bug report (2): no-crash without handler, no queued input action
  (stubs for when #495 WRONG button lands)
- Gauntlet UI hidden (3): no gauntlet nodes visible in default mode,
  no room_id/gauntlet_mode in normal snapshots, stays hidden across
  multiple ticks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:46 +01:00
jpmschweitzerandClaude Opus 4.6 f82f97afcb feat(client): add 12 P3 tests — z-layer, entity lerp, Tyre additions (#493)
gdUnit4 tests covering:
- Z-layer ordering (4): floor below ysort, entities in ysort at z:0,
  fog exact z_index values (Z_FOG=900, Z_FOG_ENTITIES=950), UI layers
- Entity lerp (3): snap on first appear, moves toward target,
  converges within 0.3s at LERP_SPEED=12.0
- Tyre additions (5): recognition progress, facing rotation, lerp
  weight scaling with delta, blob removal, LERP_SPEED constant guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:42 +01:00
jpmschweitzerandClaude Opus 4.6 5820609594 feat(client): add 16 P2 tests — camera, entity alpha/color, UI (#492)
gdUnit4 tests covering:
- Camera (5): zoom default, smoothing convergence, viewport tracking,
  follow movement, no-panning lock per D-014
- Entity alpha/color (4): null visibility default, peripheral/forward
  transition, terrain object color, player color constant
- UI (7): monologue visible/hidden, interaction verbs, inventory grid,
  dialogue overlay, pause indicator, fog blob count, fog z_index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:37 +01:00
jpmschweitzerandClaude Opus 4.6 1435a77dcc fix(client): fix MessagePack int_64 encoder dead code branch (#516)
The int_64 branch condition `-(1 << 63) <= value` overflowed in
GDScript's signed 64-bit arithmetic, making the branch unreachable.
Negative values beyond int_32 range were incorrectly encoded as
uint_64 (0xcf) instead of int_64 (0xd3). Replaced with `value < 0`.

Updated boundary tests BV-N15 and BV-N16 to expect correct int_64
header byte.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:29 +01:00
jpmschweitzer 58072bc119 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-18 01:57:07 +01:00
jpmschweitzer ca84753182 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-18 01:56:48 +01:00
jpmschweitzerandClaude Opus 4.6 44d6960758 chore(meta): update changelog for protocol v8 sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:44 +01:00
jpmschweitzerandClaude Opus 4.6 674c7147de feat(client): sync protocol to v8 — dialogue_response + Interact encoding
Bump PROTOCOL_VERSION from 7 to 8 to match server. Three changes:

1. Add dialogue_response field decoding (DialogueResponseEvent with
   line_id, text, speaker_entity_id) from server #305/D-028.

2. Fix Interact encoding: server changed PlayerAction::Interact from
   unit variant to struct variant with {target_entity_id, verb}.
   Extract _encode_action() helper to handle this consistently.

3. Update all test assertions that checked version=7 or expected
   Interact as a bare string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:27 +01:00
jpmschweitzerandClaude Opus 4.6 dcb76d1221 fix(client): fix monologue duplication test using wrong poll path
test_monologue_not_duplicated_after_consumption was failing because
poll_snapshot() in test mode returns _test_snapshot() without
consuming _last_snapshot. The carry-forward logic then incorrectly
preserved the monologue. Fix: directly clear _last_snapshot to
simulate the live-mode consumption path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:18 +01:00
jpmschweitzerandClaude Opus 4.6 dde68104fc chore(config): add test-client Cargo.lock and gitignore target/
Follows existing tooling crate pattern (content-converter, line-previewer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:40:35 +01:00
jpmschweitzerandClaude Opus 4.6 932a708cde chore(meta): update changelog for #480 and #481
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:56 +01:00
jpmschweitzerandClaude Opus 4.6 d5a1f6dadb refactor(simulation): apply rustfmt formatting
Formatting-only changes across server source and test files.
No logic changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:44 +01:00
jpmschweitzerandClaude Opus 4.6 1da81a21ea feat(simulation): add test client binary scaffolding (#480)
Separate workspace crate at tooling/test-client/ importing bridge
types from server crate. CLI: --connect, --replay, --text, --json,
--quiet, --golden, --ticks. Exit codes: 0=success, 1=golden mismatch,
2=connection error. Golden file comparison with recursive JSON diff.
JSONL replay loader for tick-scheduled input sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:37 +01:00
jpmschweitzerandClaude Opus 4.6 f83815fd9a feat(simulation): add snapshot text renderer library (#481)
format_snapshot_text(&ObserverSnapshot) -> String pub-exported from
server crate. Entity labels as kind:entity_id sorted by distance,
room name stubbed as (unknown) until Gauntlet constants land.
10 unit tests. Consumed by tooling/test-client for --text mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:28 +01:00
jpmschweitzerandClaude Opus 4.6 f7712fb4b8 chore(meta): update changelog for Sprint 9 planning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:32:38 +01:00
jpmschweitzerandClaude Opus 4.6 e3890117d0 chore(db): snapshot database with Sprint 9 ticket assignments
23 tickets assigned to Sprint 9 (Gauntlet) in planning status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:32:31 +01:00
jpmschweitzerandClaude Opus 4.6 85a3e87d36 docs(sprints): add Sprint 9 Gauntlet briefing files
Sprint 9 theme: Gauntlet — test infrastructure backbone with test
client binary, Gauntlet rooms 1-3, golden file suite, and client QA
harness. 23 tickets across server (9), client (6), CI (6), audio (2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:32:28 +01:00
jpmschweitzerandClaude Opus 4.6 0ed719f8b9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:26:39 +01:00
jpmschweitzerandClaude Opus 4.6 053380103a feat(audio): add weapon aim lock and stance change audio assets for #440
Delivers final 2/6 interaction UI audio assets, completing ticket #440:
- sfx_weapon_aim_lock.ogg (0.5s, clinical targeting lock tone)
- sfx_stance_change.ogg (0.35s, soft mechanical stance toggle click)

Both insert-tech aesthetic, UI Sounds bus, non-positional. Generated
via Stable Audio Open, trimmed, LUFS-normalized, converted to ogg.
Updated docs/assets/audio/ui.md with UI-007 and UI-008 entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:26:27 +01:00
jpmschweitzerandClaude Opus 4.6 4af2197c59 fix(ci): address PR review — filter comments, trim whitespace, warn on missing scripts
Address all review comments from Hoshe and Tyre on PR #27:
- Remove 2>/dev/null from pre-pr-fixtures (critical: swallowed errors)
- Remove dead _file_type function
- Check 4: error on districts with no locations declared
- Check 5: print advisory message when skipping
- Check 8: cross-file line ID uniqueness (not just per-file)
- Check 9: document D-034 asymmetric relationships in docstring
- Document regex fallback rationale in _scan_knowledge
- Add D-035 decision trace to schema descriptions
- Use concrete protocol version in DEVOPS.md example
- Amend D-035 with focused (9th mood) and greeting (14th situation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 00:23:21 +01:00
jpmschweitzerandClaude Opus 4.6 06f47747c7 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:44:15 +01:00
jpmschweitzerandClaude Opus 4.6 16cccda411 fix(schema): add missing dialogue mood and situation values
Adds 'focused' to mood enum and 'greeting' to situation enum in
dialogue-pool.schema.json. Both values are used by Sprint 7-8
dialogue content but were not added to the schema at the time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:43:55 +01:00
jpmschweitzerandClaude Opus 4.6 65d3b5e50c feat(ci): add content cross-reference validation (9 checks)
Extends tooling/validate-content with Pass 2 cross-reference
validation via ContentIndex class. Nine checks:

1. canonical_id uniqueness (ERROR)
2. relationship target resolution (ERROR)
3. location slug resolution (ERROR)
4. dialogue location resolution (ERROR)
5. fact_id resolution (ERROR, advisory when catalogs empty)
6. triangle membership resolution (ERROR)
7. npc_count accuracy (WARNING)
8. dialogue line_id uniqueness (ERROR)
9. bidirectional relationship consistency (WARNING)

Pass 2 only runs if Pass 1 (schema) passes. Absorbs check-fact-ids
functionality. Spec from hoshe-round3.md Section 2.

Ticket: #464

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:43:47 +01:00
jpmschweitzerandClaude Opus 4.6 f06c8be313 feat(ci): add make pre-pr target and fixture staleness check
Implements make pre-pr chain: lint -> build -> test -> content
validation -> fixture staleness. Branch-specific variants:
pre-pr-server, pre-pr-client, pre-pr-content.

Fixture staleness is a blocker (exit 1) — stale fixtures cause
false positive client tests. Spec from hoshe-round3.md Section 5.

Tickets: #460, #465

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:43:40 +01:00
jpmschweitzerandClaude Opus 4.6 c0f6afa082 chore(db): snapshot database after Sprint 8 PR merges
7 client tickets closed (#435-437, #470, #476-478) after
PR #25 and PR #26 reviews completed and merged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:29:14 +01:00
jpmschweitzer a5c30f0101 Merge remote-tracking branch 'origin/server' 2026-02-17 18:21:53 +01:00
jpmschweitzerandClaude Opus 4.6 7af85f126f chore(meta): update changelog for PR #26 review fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:13:07 +01:00
jpmschweitzerandClaude Opus 4.6 12d1fd505e fix(simulation): address PR #26 review comments (13 items)
Warnings fixed:
- Add WalkAway to all_player_action_variants_roundtrip test
- Warn and skip on unresolvable speaker_entity_id (was silent 0)
- Change MonologueState.shown_ids from Vec to HashSet (O(1) lookup)
- Add cross-plugin ordering: trigger_recognition_monologue after
  detect_anomalies (latent determinism bug)
- Add TODO for unreachable Secret trust tier

Suggestions addressed:
- Server-side range check for Talk verb in handle_talk (CLOSE_RANGE)
- Emit IncompleteInteraction before overwriting ActiveDialogue
- Add different_seed_produces_different_replay determinism test
- Replace panic with assert for unknown fixture naming convention
- Fix duplicate "Observe" label: ExamineNpc now uses "Examine NPC"
- Change DialogueCooldownTracker.used from Vec to BTreeMap (D-041)
- Add .after(process_talk_interaction) to process_walk_away ordering
- Collapse dead conditional in main.rs (both branches identical)

468 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:12:45 +01:00
jpmschweitzerandClaude Opus 4.6 a5ab8238e1 chore(meta): update changelog for Sprint 8 server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:42:16 +01:00
jpmschweitzerandClaude Opus 4.6 9c60325c2d chore(engine): update and add msgpack fixtures for protocol v8
Update existing snapshot fixtures for protocol v8 (dialogue_response
field). Add 14 raw boundary value fixtures and 5 snapshot boundary
tick fixtures for encoding edge case coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:40 +01:00
jpmschweitzerandClaude Opus 4.6 b1fdeabb7c test(simulation): sprint 8 test suite — pause guards, registry, boundary, determinism
Add 50+ tests: pause guard suite (movement, unpause, roundtrip, stance,
interact, batch, tick_rate), EntityRegistry lifecycle (stale mapping,
re-register, unknown unregister), boundary value encode/roundtrip (41
values), encoding asymmetry (GDScript signed→Rust unsigned), malformed
batch rejection, determinism gauntlet (20-tick replay), per-fix
determinism unit tests, and recognition monologue integration tests.
Fix pause guard to block all actions except Pause/Unpause while paused.
Fixes #461-463, #466-469, #471-473, #479.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:33 +01:00
jpmschweitzerandClaude Opus 4.6 35f55cfa46 feat(simulation): dialogue pipeline, ContentSlug, and walk-away KG recording
Implement full dialogue selection pipeline (D-028): 4-layer filtering
engine with access tier, situation derivation, trust tier, and weighted
topic+mood scoring via SimRng. Add ContentSlug component for stable
content identity across save/load. Add walk-away KG recording with
IncompleteInteraction events per D-064 three-phase consequences. Bump
protocol to v8 with DialogueResponseEvent. Fixes #305, #427, #452.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:23 +01:00
jpmschweitzerandClaude Opus 4.6 695d2ac843 feat(perception): anomaly detection and recognition monologue during delay
Add AnomalyMarker component and detect_anomalies() system that flags
entities with KG relationship PersonOfInterest or Contradicted state
for urgent cognitive delay (0.3s vs 0.6s normal). Add
trigger_recognition_monologue() that fires monologue at delay START
(when grey blob appears), not at completion — the monologue IS the
recognition process per D-060. Includes v0.1 fallback recognition
lines and cooldown tracking. Fixes #450, #451.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:14 +01:00
jpmschweitzerandClaude Opus 4.6 ed7498cb24 feat(simulation): add --test-mode, --port, --seed CLI flags
Add CLI argument parsing for test infrastructure: --test-mode enables
deterministic seed (42) and warn-level tracing to stderr, --port allows
OS-assigned ports (--port 0), --seed overrides RNG seed. Prints
LISTENING:{port} to stdout after bind for test harness discovery.
Extracts setup_proof_room() for reuse. Fixes #459.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:05 +01:00
jpmschweitzerandClaude Opus 4.6 af8e20ab9a fix(simulation): determinism fixes — BTreeSet ordering, entity sort, mover sort
Replace HashSet with BTreeSet for visible_ids, sort visible_tiles by
coordinates, sort visible entities in snapshot by entity_id, and sort
movers by Entity bits in validate_movement. Required by D-010 principle 4
(deterministic simulation). Fixes #456, #457, #458.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:40:57 +01:00
jpmschweitzerandClaude Opus 4.6 2ab41a0f04 fix(client): address PR #25 review — 5 critical bugs, 4 warnings, 3 suggestions
Critical fixes:
- hide_dialogue() sent PAUSE instead of UNPAUSE, permanently freezing
  simulation after every dialogue (both reviewers)
- Confrontation monologue hardcoded in GDScript constant, violating
  D-042/D-020 — moved to ui-strings.yaml as dialogue.confrontation_beat
- Walk-away WASD didn't call set_input_as_handled(), letting movement
  event propagate and potentially stepping on the same frame
- is_dialogue_active() returned _is_showing only — interaction list
  could flash during 300ms fade gap. Now includes dialogue_active state
- Removed dead _last_dialogue_id / get_dialogue_id() state (never read)

Warnings addressed:
- Audio registry now scans res://audio/ recursively (subdirs registered)
- add_bus_effect guarded against duplicate calls in tests
- int64 encoder dead code tagged KNOWN-DEFECT, filed as ticket #516
- D-073 zone crossfade stub comment clarifies Sprint 9+ deferral
- listening_focus dip documents caller tick-gate responsibility (D-069/D-071)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:33:56 +01:00
jpmschweitzerandClaude Opus 4.6 4ccb73d364 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:35:47 +01:00
jpmschweitzerandClaude Opus 4.6 afc433be34 test(client): add boundary, P0 regression, P1 fog/entity, protocol v7 tests
MessagePack boundary tests: 41 values (25 pos + 16 neg) with encode-
only verification, roundtrip, and Rust-style unsigned decode (#470).
P0 regressions: monologue carry-forward (Bug #5), camera stability
during pause (Bug #2) (#477). P1 tests: fog shader state (4), entity
lifecycle (2), pending recognition blob (1) using FogState named
constants (#478). Protocol v7: structured dialogue options with
response_id, priority, confrontation flag, malformed skip (#435).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:35:19 +01:00
jpmschweitzerandClaude Opus 4.6 be763908b2 refactor(client): replace fog byte magic numbers with named constants
Define VIS_HIDDEN/PERIPHERAL/FORWARD and EXP_UNEXPLORED/EXPLORED/
VISIBLE in FogState. Replaces 7 magic number usages in _resize() and
update_from_state(). Provides stable assertion targets for fog tests.
Implements #476.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:35:05 +01:00
jpmschweitzerandClaude Opus 4.6 bfc699c15d feat(ui): implement dialogue pipeline — selection, walk-away, confrontation
Protocol: decode current_dialogue with structured options {text,
response_id, priority, confrontation} and npc_entity_id (#435).
Dialogue box: priority sort, max 3 visible, RichTextLabel for BBCode
italic confrontation options (D-063), 1.5s monologue beat with audio
dip before confrontation send. Walk-away: WASD triggers WalkAway
input, 300ms fade, dialogue_active flag gates movement (D-064).
Implements #435, #437, #436.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:34:55 +01:00
jpmschweitzerandClaude Opus 4.6 9e6b859cdf feat(audio): add AudioManager autoload with 5-bus architecture
D-068 bus layout (Music, Ambient, WorldSFX, PlayerActions, UISounds),
directory-scan asset registry, spatial/non-spatial playback, D-069
audio dip profiles (dialogue, confrontation, listening_focus) with
low-pass filter sweep, and D-073 zone crossfade stub. No-op fallback
when audio assets absent. Implements #255.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:34:42 +01:00
jpmschweitzerandClaude Opus 4.6 600cfe506c chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:00:37 +01:00
jpmschweitzerandClaude Opus 4.6 a87c95a6eb docs(workshops): complete QA test architecture workshop
3-round workshop with 7 agents (Tyre, Dudley, Stig, Hoshe,
Justine, Gestalt, Ozzie) plus Qatux documenting. Produced:

- 59-item prioritized test backlog (60 tickets under epic #455)
- Gauntlet test world spec: 7 rooms + hub, 48 entities
- Test client binary spec (tooling/test-client/)
- Determinism fixes (3 patches, ~22 lines)
- Server --test-mode + --port 0 design
- Content cross-reference validation (9 checks)
- make pre-pr pipeline (6-step)
- 38 client tests prioritized
- Anti-tedium features (reset plate, hub teleport, WRONG button)
- Human tester walkthrough
- CI pipeline design (deferred but documented)

Sprint 8 scope: ~17.75 team-days across 26 tickets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:00:15 +01:00
jpmschweitzerandClaude Opus 4.6 7043fd7e4f chore(skills): require file output in workshop skill
Agents were sending outputs as messages instead of writing files,
which prevented Qatux (documenter) from reading other agents' work.
Now each round task instructs agents to write to
docs/workshops/{name}/{agent}-roundN.md, and between-round steps
verify files exist before proceeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:59:55 +01:00
jpmschweitzerandClaude Opus 4.6 6bf075ed8e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:27:28 +01:00
jpmschweitzerandClaude Opus 4.6 21edbfec19 docs(workshops): add test architecture workshop brief
4-track workshop targeting the class of bugs from Sprint 6-7: blocking
I/O, serialization boundaries, state desync, off-by-one encoding.
Participants: Tyre, Hoshe, Dudley, Stig. Covers integration test
architecture, serialization boundary tests, client test infrastructure,
and test gap prioritization. Includes catalogue of 6 real bugs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:41 +01:00
jpmschweitzerandClaude Opus 4.6 855dc72b58 docs(scope): update D-053 with client throttle and input model
Document client-side movement throttle rates per stance, hold-to-move
input model with composite diagonals, and crouch ticks-per-move value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:34 +01:00
jpmschweitzerandClaude Opus 4.6 3a02fd0d2a feat(client): add camera anchor test suite
10 gdUnit4 tests verifying camera behavior: SimBridge test mode
connection, snapshot pipeline, player position extraction, camera
anchor in _ready(), smoothing disable/re-enable cycle, camera tracking
across frames, and player movement following.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:28 +01:00
jpmschweitzerandClaude Opus 4.6 8bf7f6e610 fix(client): entity lerp, fog shader safety, type fixes
Entity renderer: add framerate-independent position lerping so entities
slide between tiles instead of snapping. Tuned for Sprint snappiness
and Walk/Careful fluidity.

Fog shader: set ColorRect to transparent fallback so a shader load
failure doesn't paint solid white over the world. Track camera position
(not player position) so fog stays synced during smooth camera pan.
Restructure GLSL to avoid early return (some GPU drivers miscompile it).

Minor type fixes: typed Array[Vector2] in cursor_renderer tick drawing,
untyped Array in inventory_grid to avoid Godot typed-array cast issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:23 +01:00
jpmschweitzerandClaude Opus 4.6 bc875d607f fix(client): correct messagepack signed int boundary encoding
Off-by-one in all four signed int branches: upper bound used <= instead
of < (e.g., value <= 128 matched int8 instead of falling through to
uint8). Tick 128 was encoded as int8 -128, causing server deserialization
failure. Also remove stray debug prints from nil/false decode paths.

Fixes bug #4 (MessagePack encodes tick 128 as -128).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:13 +01:00
jpmschweitzerandClaude Opus 4.6 ee3ac75578 fix(client): add pause toggle, hold-to-move, snapshot event carry-forward
Three client input/bridge fixes:

1. Pause toggle: add UNPAUSE action and toggle logic based on
   GameState.game_time.tick_rate. Wire UNPAUSE in sim_bridge.gd.

2. Hold-to-move: replace press-event movement with polled _process()
   direction sampling. Composite diagonals via simultaneous keys
   (W+D → northeast). Client-side throttle per stance (D-053):
   Sprint=200ms, Walk=400ms, Careful=600ms, Crouch=800ms.

3. Snapshot carry-forward: when a newer snapshot overwrites an
   unconsumed one, carry forward current_monologue and current_dialogue
   so one-shot events aren't silently dropped.

Fixes bugs #5 (monologue lost on overwrite) and #6 (overwrite spam).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:07 +01:00
jpmschweitzerandClaude Opus 4.6 2dd1e9545d fix(client): anchor camera to player before first frame renders
Camera2D's internal smoothed_camera_pos starts at (0,0) and lerps
toward global_position, causing a visible white-screen drift on startup.
Disable position_smoothing in _ready(), snap camera to player position
from the first snapshot, then re-enable smoothing with reset_smoothing()
after the first anchored frame. Add tick guards to monologue/dialogue
consumption to prevent re-triggering when client FPS > sim tick rate.

Fixes bug #2 (camera doesn't center on player at startup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:25:58 +01:00
jpmschweitzerandClaude Opus 4.6 2b54697278 fix(simulation): discard movement input while paused
process_player_input processed movement commands regardless of tick
rate, causing the camera to drift from the player when the game was
paused and the player held direction keys. Add PlayerAction::is_movement()
helper and a paused guard that skips movement actions while TickRate is
Paused. Pause/Unpause commands still process normally.

Fixes bug #3 (player moves while game is paused).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:25:51 +01:00
jpmschweitzerandClaude Opus 4.6 d1aba3d554 fix(simulation): use non-blocking TCP to prevent game loop stall
read_framed() called read_exact() which blocked the entire bevy Update
schedule waiting for client input — no systems ran until a keystroke
arrived. Switch TcpStream to non-blocking mode so receive_inputs()
returns Ok(vec![]) on WouldBlock instead of blocking. Toggle to
blocking for snapshot writes (reliable delivery). Add 50ms frame
throttle (~20 ticks/sec) since the non-blocking loop would otherwise
spin. Downgrade input receive logging to trace, add error logging for
missing bridge resource and failed observer queries.

Fixes bug #1 (server never sends snapshots) and #2 (camera doesn't
center until first keystroke).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:25:45 +01:00
jpmschweitzerandClaude Opus 4.6 686c89170a chore(db): snapshot database after Sprint 7 completion
Sprint 7: Talk completed (13/13 done).
Sprint 8: React activated (10 tickets, 2 carry-overs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:34:46 +01:00
jpmschweitzerandClaude Opus 4.6 be168d59ef refactor(client): rename test_protocol_v6.gd to test_protocol_bridge.gd
Version numbers in test filenames go stale every sprint. The file tests
bridge protocol features (stance, inventory, fixtures) not a specific
protocol version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:33:53 +01:00
jpmschweitzerandClaude Opus 4.6 6d6b59c17b fix(client): address PR #24 review — 5 critical bugs, 3 warnings, 8 suggestions
Critical:
- Protocol test assertions updated v6→v7 (test_protocol_v6.gd)
- Dialogue signal connections wired (option_selected→DialogueResponse,
  dialogue_dismissed→DialogueEnd sent to server via SimBridge)
- Consume-once race fixed: _consume_dialogue() checks is_dialogue_active()
  before re-showing; dialogue_id tracking prevents re-trigger during fade
- Dialogue box responsive: _update_layout() clamps width to MAX_WIDTH_PX
  (832px) or 65% viewport, height to 20% viewport (MAX_HEIGHT_RATIO)
- Auto-pause added: SimBridge.send_input(PAUSE) on dialogue open/close

Warnings:
- Test coverage: 16 new tests in test_protocol_v7.gd (pending_recognitions
  decode, current_dialogue, GameState, SimBridge mock data, insert colors)
- queue_redraw() optimization: early return when no entities and no pings
- Fixed 200px height → responsive 20% viewport via _update_layout()

Suggestions:
- WASD detection refactored to _WALK_AWAY_ACTIONS array loop
- Button colors reference Constants.INSERT_COLOR_TEXT/HOVER/ACTIVE
- Named constants: COLOR_TRANSITION_START, SILHOUETTE_APPEAR_THRESHOLD,
  SILHOUETTE_SIZE with explanatory comments
- Bounds check: MAX_PENDING_RECOGNITIONS=64 with truncation warning
- Mock dialogue sustained across ticks (not 1-tick flash)
- COLOR_PING coupling with cursor documented as intentional
- Consume helpers extracted: _consume_monologue(), _consume_dialogue()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:30:15 +01:00
jpmschweitzer 90a80e73df Merge remote-tracking branch 'origin/main' into client 2026-02-16 01:26:36 +01:00
jpmschweitzerandClaude Opus 4.6 7873b77a5c chore(meta): add Sprint 8 briefings and DB backup
Sprint 8: React — briefings for server, client, audio, and joint teams.
Database snapshot after Sprint 8 ticket assignments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:26:04 +01:00
jpmschweitzer 7edc513632 Merge remote-tracking branch 'origin/audio'
# Conflicts:
#	CHANGELOG.md
2026-02-16 01:25:40 +01:00
jpmschweitzerandClaude Opus 4.6 91c90829b7 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:23:16 +01:00
jpmschweitzerandClaude Opus 4.6 6035d2049a feat(audio): add 6 interaction UI audio assets for #440
Generated via Stable Audio Open with sonic family prompt prefixes:
- cursor_hover.ogg (insert-tech, digital click)
- weapon_aim.ogg (insert-tech, mechanical latch sketch)
- implant_open.ogg (insert-tech, neural lattice rising tone)
- fog_recognition.ogg (organic, cognitive realization chime)
- sfx_monologue_chime.ogg (insert-tech, placeholder per D-071)
- sfx_monologue_chime_urgent.ogg (insert-tech, placeholder per D-071)

All assets need duration trimming in next audio sprint (#453).
Updated docs/assets/audio/ui.md status to draft.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:22:56 +01:00
jpmschweitzerandClaude Opus 4.6 725dc4684a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:19:40 +01:00
jpmschweitzerandClaude Opus 4.6 2659de0c28 feat(client): add fog entity visualization and wire Sprint 7 UI (#431, #434)
Fog entity cognitive delay rendering (D-059/D-060): sonar-style sound
pings (3 concentric rings, 1.5s fade), unrecognized grey blobs with
breathing pulse, D-033 color transition at 50% recognition progress,
±0.5 tile position drift. FogEntities node at z:950 between fog
shader and InsertOverlay.

Protocol v7 bump to match server PR #23 (pending_recognitions field).
Wires dialogue box and fog entities into game loop with mock test data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:19:24 +01:00
jpmschweitzerandClaude Opus 4.6 2d56131221 feat(ui): add dialogue box — bottom screen, max 20% height, walk-away (#434)
D-061 compliant UI skeleton: NPC speech top, max 3 response options
below, left-aligned. Insert-styled colors (white-blue, amber hover,
green pressed). WASD walk-away fades over 300ms, no close button.
InsertOverlay (CanvasLayer 10, z-layer 6). Mock data in next commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:19:15 +01:00
jpmschweitzerandClaude Opus 4.6 88fc344ed9 docs(sprints): add protocol v7 bump requirement to client briefing
Server PR #23 bumped PROTOCOL_VERSION to 7 (pending_recognitions field).
Client must match before #431 fog entity visualization or joint testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:08:29 +01:00
jpmschweitzer 307124cee6 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-16 01:08:17 +01:00
jpmschweitzerandClaude Opus 4.6 d0d545f44d feat(audio): add dip spec and synthesis tooling for #440
dialogue-ambient-dip.md: full Godot implementation spec for dialogue
(-7dB ambient) and confrontation (-11dB ambient + LP 800Hz, -5dB world
SFX) audio dips with tween code and edge case handling. synth_ui_sounds
.py: FM/noise/impulse synthesis for insert-tech UI sounds (cursor hover,
weapon aim, monologue chimes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:04:44 +01:00
jpmschweitzerandClaude Opus 4.6 d4fdbf426e fix(server): address PR #23 review — 4 critical bugs, 3 warnings, 11 suggestions
Critical fixes:
- Add PendingRecognitionWire serialization roundtrip test
- Check Option return from delay.cancel() before logging
- InputQueue capacity limit (1000) with drop-oldest and warning
- TODO in observation.rs references ticket #450

Warning fixes:
- Hot-reload guards against invalid/empty content root
- Location header mismatch warning in line pool indexing
- walk_yaml() depth limit (100) against symlink loops
- Consecutive reload failure counter (warns after 5+)

Test additions:
- Negative prerequisite filtering test for monologue lines
- Integration test for pending_recognitions in observer snapshot
- Eavesdrop threshold ordering assertion (Careful < default)

Documentation:
- Playtesting expectation comments on delay constants
- is_pending() scalability note for future NPC cognitive delay
- ID format regex validation in line pool spec
- BTreeMap vs sort() ordering clarification in loader
- Multiplayer TODO in relationships.rs references D-010
- ContentSlug ticket #452 filed for entity slug resolution

394 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:51 +01:00
jpmschweitzerandClaude Opus 4.6 c5f0694a1b feat(skills): add gen-audio skill, rename asset-gen to gen-image
gen-audio: prompt assembly system (sonic palette + category templates +
asset descriptions), SAO generation workflow, post-processing pipeline,
manual synthesis guide for sub-200ms insert-tech sounds. Rename
asset-gen to gen-image for consistent gen-* naming. Add audio connector
permissions to settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:45 +01:00
jpmschweitzerandClaude Opus 4.6 094939a33d feat(db): add Stable Audio Open connector and post-processing wrappers
audio-generate: Gradio API wrapper with timeout handling (600s default),
sequential-only generation, SSE stream polling. audio-health: API
health check. audio-post: ffmpeg wrapper for convert/normalize/trim/
pipeline operations. All follow existing connector pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:30 +01:00
jpmschweitzerandClaude Opus 4.6 34d0e7211c docs(assets): add asset pipeline documentation system
Category-based asset index for audio, visual, and video pipelines.
Audio pipeline fully populated: palette (sonic identity bible), ambient
loops, SFX, UI sounds with generation specs and mix parameters.
Visual and video are stubs pending their respective workshops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:18 +01:00
jpmschweitzerandClaude Opus 4.6 ac86c8a72b docs(decisions): record audio discussion outcomes D-067 through D-074
Recognition chime timing (D-067), 5-bus audio architecture (D-068),
audio dip profiles (D-069), confrontation as cognitive vulnerability
(D-070), monologue chime placeholder strategy (D-071), universal
conversation murmur (D-072), zone crossfade (D-073), hybrid audio
generation (D-074). Amends D-038 scope, resolves Q-014.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:02 +01:00
jpmschweitzerandClaude Opus 4.6 8a334b47f3 chore(meta): update changelog
Sprint 7 server entries: cognitive delay, ListeningFocus, YAML loader,
line pool spec, interaction memory schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:42:13 +01:00
jpmschweitzerandClaude Opus 4.6 087ff67bfb docs(architecture): add interaction memory KG schema design (#442)
Design doc at docs/architecture/interaction-memory-schema.md extending
FactKnowledge with interaction tracking. FactMetadata enum on existing
BTreeMap, 5-state InteractionState (Active/Completed/WalkedAway/
Confrontation/ConflictAbandoned), monologue prerequisite extension.

Includes entity slug resolution approach, NpcTolerance reconciliation,
and fact decay exemption documentation.

Ref: D-041, D-064, D-063

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:41:40 +01:00
jpmschweitzerandClaude Opus 4.6 91d6b1b1cb feat(simulation): add YAML content loader with hot-reload (#326)
LinePool system parses dialogue.yaml and monologue.yaml into indexed
BTreeMap structures. Supports 4-layer query filtering (access >
situation > trust > topic+mood) per D-028. Hot-reload via timestamp
polling every 20 ticks (dev-only). Graceful failure preserves
previous content on reload error.

30+ tests covering enum parsing, index building, query filtering,
monologue fallback, and content watching.

Ref: D-028, D-032, D-035, D-041

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:41:29 +01:00
jpmschweitzerandClaude Opus 4.6 ea0a3f0b35 docs(architecture): add line pool format specification (#308)
Formal spec at docs/architecture/line-pool-format.md defining YAML
structure for dialogue and monologue content files. Covers tag enums,
4-layer filtering pipeline, prerequisite-to-KG mapping, ID format,
validation rules, and Rust loader interface.

Fixes monologue schema: adds required constraint on relationship
prerequisite target/state fields.

Ref: D-028, D-032, D-035, D-041

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:41:18 +01:00
jpmschweitzerandClaude Opus 4.6 4d8fe996fc feat(perception): add cognitive delay system for fog recognition (#423)
New CognitiveDelay component buffers perception events before emitting
KnowledgeEvents. Normal delay: 6 ticks (0.6s), urgent: 3 ticks (0.3s).
Drain system runs after emit_observation_events, before process_knowledge_events.

Adds pending_recognitions to ObserverSnapshot (protocol v7) for client
fog entity visualization. Includes cancellation when entity leaves LOS.

Monologue fires during delay (not after) per D-060 — cross-system
wiring deferred to follow-up ticket #451.

Ref: D-060 (cognitive delay), D-031 (10 tps), D-059 (fog layers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:41:08 +01:00
jpmschweitzerandClaude Opus 4.6 3a2cfc37e0 feat(simulation): add ListeningFocus eavesdrop positioning (#426)
New ListeningFocus component tracks stationary_ticks for eavesdrop
mechanic. Increments when position unchanged, resets on movement.
Sprint stance blocks accumulation, Careful reduces threshold from
30 to 20 ticks. Registered in SimulationPlugin after validate_movement.

17 tests covering all stances, thresholds, and edge cases.

Ref: D-053 (stance system), D-018 (sound model)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:40:54 +01:00
jpmschweitzerandClaude Opus 4.6 d0663c4193 refactor(server): apply rustfmt and clippy suggestions
Formatting pass across simulation, perception, knowledge, NPC, and
test modules. Includes two clippy fixes in monologue.rs (.values()
instead of for (_, v) pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:40:43 +01:00
jpmschweitzerandClaude Opus 4.6 b218338223 chore(db): snapshot database after copy merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:36:42 +01:00
352 changed files with 49449 additions and 725 deletions
+4
View File
@@ -36,6 +36,10 @@
"Bash(db/connectors/sqlite-init)",
"Bash(db/connectors/decisions-sync)",
"Bash(db/connectors/audio-generate *)",
"Bash(db/connectors/audio-health)",
"Bash(db/connectors/audio-post *)",
"Bash(make *)",
"Bash(make)",
+5 -1
View File
@@ -178,7 +178,11 @@ For design patterns, consult:
**Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:**
- `name`: The skill name
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
The domain is the system/area the skill operates on, the action is what it does.
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
Multi-action wrappers (like `ticket`) can use the domain name alone.
The directory name must match the `name` field.
- `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body).
+187
View File
@@ -0,0 +1,187 @@
---
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.
@@ -0,0 +1,67 @@
# Category Templates — Audio Generation
Match the asset's category to the right template. Append after the sonic family
prefix and before the specific asset description.
## Ambient Loops
**Template:**
> Continuous ambient soundscape loop, seamless looping audio, no distinct
> beginning or end, steady background atmosphere, [DURATION]s duration
**Notes:**
- Generate at 45s (the SAO sweet spot below the 47s ceiling)
- Add 3-5s of overlap material for crossfade looping in post
- These play continuously — they must be boring enough to fade into the
background but rich enough to reward attention
- Station baseline prefix is required (see sonic-palette.md)
**Steps:** 100-150 (higher quality for loops — artifacts are more noticeable)
**CFG:** 5-7 (lower guidance = more natural variation, less mechanical)
## Sound Effects (SFX)
**Template:**
> Single isolated sound effect, clean recording, [DURATION] duration,
> clear attack and natural decay, no background noise, no reverb tail
> beyond natural
**Notes:**
- For footsteps: specify surface material, pace, weight
- For environmental: specify the physical mechanism (door hinge, cargo latch)
- Keep short and punchy — these are event-driven, not continuous
**Steps:** 80-100 (shorter sounds need fewer steps)
**CFG:** 7-9 (more guidance = more precise sound matching)
## UI Sounds
**Template:**
> Interface feedback sound, digital UI element, [DURATION] duration,
> immediate attack, clean decay, isolated sound with no background
**Notes:**
- Most UI sounds are under 200ms — use manual synthesis, not SAO
- For SAO-generated UI sounds (>200ms like fog_recognition, chimes):
generate at 3-5s duration, then trim to the best portion
- Insert-tech prefix is required for lattice/interface sounds
- Organic prefix for fog_recognition (cognitive, not technological)
**Steps:** 80-100
**CFG:** 7-9 (precision matters for UI sounds)
## Chimes / Notification Sounds
**Template:**
> Single musical tone, crystalline quality, [DURATION] duration, gentle
> attack, resonant sustain, natural fade out, no accompaniment, no rhythm
**Notes:**
- Generate at 3-5s to give SAO enough temporal context
- Trim to the best 0.5-1.0s section
- The attack transient is where the character lives — select for that
- Normal vs urgent variants: same base prompt, vary "gentle/soft" vs
"brighter/sharper/more present"
**Steps:** 100 (tonal quality matters)
**CFG:** 6-8 (some freedom for natural harmonic content)
@@ -0,0 +1,45 @@
# Sonic Palette — Prompt Prefixes
Two sonic families. Every generated sound uses one of these as a prompt prefix.
## Insert-Tech (Synthetic)
For UI sounds, neural lattice interface, augmented cognition.
**Prompt prefix:**
> Clean digital audio, synthetic electronic tone, precise and clinical sound
> design, no reverb, no room ambience, studio-dry recording, futuristic
> interface sound
**Character:** Mathematical precision. The sound of well-designed technology.
No organic texture, no room reflections. Exists "inside the head," not in
physical space.
**Frequency range:** 800Hz-4kHz primary. Sharp attack, controlled decay.
**Used for:** cursor_hover, implant_open, weapon_aim, sfx_monologue_chime,
sfx_monologue_chime_urgent
## Organic (Biological/Environmental)
For environmental sounds, human cognition, physical world.
**Prompt prefix:**
> Warm organic audio, natural sound recording, slight room ambience,
> lived-in industrial space, realistic acoustic properties, authentic
> environmental sound
**Character:** Human warmth. Natural attack and decay. Reverberant — exists
in physical space. The sound of a real place with real materials.
**Frequency range:** 60Hz-3kHz primary. Soft or natural attack, room tail.
**Used for:** fog_recognition, all amb_* ambient loops, all sfx_footstep_*
## Station Baseline
For ambient loops specifically, add the station context:
> Interior of a large industrial space station, metal and composite
> construction, background mechanical hum from power systems, air circulation
> audible, no music, no prominent voices unless specified
@@ -1,5 +1,5 @@
---
name: asset-gen
name: gen-image
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).
+19
View File
@@ -103,6 +103,25 @@ tea pr create \
Report PR URL when done.
### 7. Update ticket status to review
Scan all commit messages in the PR for ticket references (`#NNN`):
```bash
git log --oneline main..<branch>
```
Extract ticket IDs from `#NNN` patterns. For each ticket that is
currently `in_progress`, update it to `review`:
```bash
db/connectors/ticket status <id> review
```
Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
+4
View File
@@ -140,6 +140,10 @@ Respond with:
- File path and approximate location
- Severity: critical / warning / suggestion
- Description of the issue
Verdict rules:
- Any **critical** or **warning** issue → REQUEST_CHANGES
- Only **suggestion**-level issues (or none) → APPROVE
If no issues found, say APPROVE with a brief positive summary.
```
+78 -10
View File
@@ -1,10 +1,12 @@
---
name: start-sprint
description: >
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
/start-sprint. Merges main into the team branch, finds the active sprint,
reads the sprint briefing, and presents the work plan with ticket details.
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.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
@@ -24,7 +26,68 @@ git branch --show-current
```
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
If on `main`, ask the user which team branch to check out first.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 2–8).
---
## Main branch workflow (sprint close + version bump)
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.
### M1. Close the active sprint
```bash
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
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
- Leave `## [Unreleased]` as an empty section above the new version.
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
### M3. Commit the release
Stage and commit `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
### M4. Tag the release
```bash
git tag v0.1.N
```
### M5. Push
```bash
git push && git push --tags
```
### M6. Report
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
---
## Team branch workflow
### 2. Sync with main
@@ -68,18 +131,23 @@ db/connectors/ticket show <id>
Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work.
### 7. Present the work plan
### 7. Mark tickets in progress and present the work plan
Output a summary:
Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
db/connectors/ticket status <id> in_progress
```
Then output a summary:
- Sprint name and goal
- Branch status (clean merge or conflicts)
- Actionable tickets (unblocked, ready to start)
- Tickets marked in_progress (list with IDs)
- Blocked tickets (and what blocks them)
- Key decisions loaded
- Suggested first task (lowest ID unblocked ticket)
Do NOT mark any ticket as `in_progress` yet.
### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
+7 -5
View File
@@ -49,8 +49,9 @@ One task per participant containing:
- Instruction to read the full brief at its path
- The specific questions assigned to that participant (extracted from all layers)
- The output format from the brief's round description
- **IMPORTANT — file output requirement:** Instruct each agent to write their full output to `docs/workshops/{name}/{agent}-round{N}.md` (e.g., `docs/workshops/test-architecture/gestalt-round1.md`). Agents must write to disk, not just send messages. This ensures Qatux and other agents can read all outputs directly without relying on message forwarding.
One task for Qatux: "Document Round 1 — read all agent responses and capture decisions, questions, and dissent."
One task for Qatux: "Document Round N — read all agent output files at `docs/workshops/{name}/*-round{N}.md` and capture decisions, questions, and dissent."
Assign all tasks using TaskUpdate with `owner` = agent name.
@@ -75,10 +76,11 @@ For large workshops (>6 agents), spawn participants in batches to avoid overwhel
### 7. Between Rounds
When all Round N tasks are complete:
1. Qatux produces round summary in `workshop-notes.md`
2. Create Round N+1 tasks (integration pass, synthesis, etc.)
3. Assign to agents with TaskUpdate
4. Agents continue working
1. Verify all agents wrote output files to `docs/workshops/{name}/`. If any are missing, nudge the agent or extract from their message and write the file yourself.
2. Qatux reads all `*-round{N}.md` files and produces round summary in `round-{N}-notes.md`
3. Create Round N+1 tasks (integration pass, synthesis, etc.) — include the same file output requirement
4. Assign to agents with TaskUpdate
5. Agents continue working
### 8. Wrap Up
+1
View File
@@ -3,6 +3,7 @@
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
tooling/test-client/target/
content-ron/
# Godot client (further ignores managed by client team)
+167
View File
@@ -6,8 +6,175 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.9] — 2026-02-18
### Fixed
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
### Added
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
### Changed
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
### Added
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
- Domain-action naming convention for skills documented in create-skill guide
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
- Reset plate StableId verification in `stable_id_ranges_match_spec`
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
### Changed
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
- `room_at()` documented with z-range and corridor overlap assumptions
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
### Removed
- Dead `RoomMember` component from reset.rs (defined but never used)
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
### Fixed
- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf
- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review)
- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review)
- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway)
- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures
- Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch
- Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb }
- Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
- Branch-specific pre-PR variants: `make pre-pr-server`, `make pre-pr-client`, `make pre-pr-content`
- Content cross-reference validation (9 checks) — canonical_id uniqueness, relationship targets, location slugs, dialogue locations, fact_ids, triangle membership, npc_count, dialogue line_ids, bidirectional relationships (#464)
### Fixed
- Dialogue schema missing `focused` (mood) and `greeting` (situation) values added in Sprint 7-8 content
- Speaker wire ID silent fallback — dialogue now warns and skips when target entity missing from registry (was silently using 0)
- Cross-plugin system ordering — trigger_recognition_monologue now runs after detect_anomalies (latent determinism bug)
- Walk-away ordering — process_walk_away now runs after process_talk_interaction (prevents same-tick race)
- ActiveDialogue overwrite — new Talk while in existing dialogue now emits IncompleteInteraction before replacing
- Server-side Talk range check — handle_talk now enforces CLOSE_RANGE before setting TalkRequest (was client-only)
- ExamineNpc label collision — VerbKind::ExamineNpc now uses "Examine NPC" label (was "Observe", same as generic Observe)
- Dead conditional in main.rs collapsed (both branches were identical)
- WalkAway variant added to all_player_action_variants_roundtrip serialization test
### Changed
- DialogueCooldownTracker.used changed from Vec to BTreeMap for O(log n) lookup (D-041 compliance)
- MonologueState.shown_ids changed from Vec to HashSet for O(1) contains check (was O(n) per tick)
- Secret trust tier documented as unreachable with TODO for Phase 2 KG-gated unlock
### Added
- Determinism test: different_seed_produces_different_replay — exercises SimRng via dialogue weighted selection
- Dialogue selection pipeline (#305, D-028) — 4-layer filtering engine: access tier from KG relationship, situation derivation from game state, trust tier, weighted topic+mood scoring via SimRng. Full Talk verb → selected line → ObserverSnapshot pipeline with cooldown tracking
- ContentSlug component (#452) — stable content identity from YAML (e.g. "kael-davan") independent of runtime Entity handles, for knowledge graph interaction memory across save/load
- Walk-away KG recording (#427, D-064) — IncompleteInteraction knowledge events with Talk/Confront type, ActiveDialogue tracking, walk-away detection clears dialogue and records in KG
- Anomaly detection for urgent recognition (#450, D-060) — AnomalyMarker flags PersonOfInterest/Contradicted entities for 0.3s cognitive delay instead of 0.6s normal
- Recognition monologue during cognitive delay (#451, D-060) — monologue fires at delay START (grey blob phase), not completion. v0.1 fallback lines, anomaly prioritization, cooldown tracking
- Server --test-mode, --port, --seed CLI flags (#459) — LISTENING:{port} stdout signal, OS-assigned ports, deterministic seed override, stderr-only tracing
- Determinism gauntlet test (#466) — 20-tick replay determinism regression test with movement, stance, pause/unpause exercise
- Pause guard test suite (#461-463, #468) — 7 tests covering movement, unpause, roundtrip, stance, interact, batch, tick_rate during pause
- EntityRegistry lifecycle tests (#469) — stale mapping, re-register, unknown unregister edge cases
- Boundary value encode/roundtrip tests (#471) — 41 values across all MessagePack integer format boundaries
- Encoding asymmetry tests (#473) — Rust decoder accepts GDScript-style signed encodings for unsigned fields
- Boundary fixture generation (#472) — 14 raw + 5 snapshot fixtures at integer format boundaries
- Malformed batch rejection test (#479) — truncated, garbage, and mixed payloads rejected atomically
- Per-fix determinism unit tests (#467) — equidistant NPC ordering, visible tile sorting, same-tile mover resolution
### Fixed
- Determinism: visible_ids HashSet → BTreeSet for stable iteration order (#456)
- Determinism: visible entities in snapshot sorted by entity_id (#457)
- Determinism: movers sorted by Entity bits in validate_movement (#458)
- Pause guard blocks all actions except Pause/Unpause while paused (previously only blocked movement)
### Changed
- Protocol version bumped from v7 to v8 (dialogue_response field in ObserverSnapshot)
### Added
- AudioManager autoload (#255, D-068/D-069/D-073) — 5-bus architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds), directory-scan asset registry, spatial/non-spatial playback, audio dip profiles (dialogue, confrontation, listening_focus) with low-pass filter sweep, zone crossfade stub
- Dialogue response selection (#435, D-061/D-062) — structured options with response_id, priority sorting, max 3 visible, invisible locked options, RichTextLabel for BBCode support
- Walk-away mechanic (#437, D-064) — WASD triggers WalkAway input during dialogue, 300ms fade, dialogue_active flag gates movement, re-show on server interrupt
- Confrontation text styling (#436, D-063) — italic first-person options, 1.5s monologue beat with dialogue dim to 70%, audio dip via AudioManager, walk-away cancels in-flight beat
- MessagePack boundary value tests (#470) — 41 values (25 positive, 16 negative), encode-only header verification, roundtrip, Rust-style unsigned decode overlap tests
- Client P0 regression tests (#477) — monologue carry-forward (Bug #5), camera stability during pause (Bug #2)
- Client P1 tests (#478) — fog shader state (4), entity lifecycle (2), pending recognition blob (1)
### Changed
- Fog byte magic numbers replaced with named constants (#476) — VIS_HIDDEN/PERIPHERAL/FORWARD, EXP_UNEXPLORED/EXPLORED/VISIBLE in FogState
- Dialogue options now carry structured {text, response_id, priority, confrontation} instead of plain strings
- Protocol v7 dialogue decode validates and skips malformed options
### Added
- QA test architecture workshop complete — 3-round, 7-agent workshop producing 60 tickets (epic #455): Gauntlet test world (7 rooms, 48 entities), test client binary (tooling/test-client/), determinism fixes, content validation, make pre-pr pipeline, 38 client tests, anti-tedium features, human tester workflow
- Workshop skill updated — agents now write output files to disk instead of sending messages, fixing documenter access
- Camera anchor test suite — 10 gdUnit4 tests verifying camera init, smoothing cycle, and player tracking
- Entity position lerping — framerate-independent exponential smoothing so entities slide between tiles instead of snapping
### Fixed
- Server never sends snapshots — blocking TCP read in receive_bridge_inputs stalled the entire bevy Update schedule; switched to non-blocking I/O with WouldBlock handling
- Camera doesn't center on player at startup — Camera2D smoothed_camera_pos starts at (0,0); now disable smoothing during init, snap to player, re-enable after first anchored frame
- Player moves while game is paused — movement commands now discarded when TickRate is Paused (pause/unpause still process)
- Spacebar only pauses, doesn't toggle — added UNPAUSE action with toggle logic based on tick_rate state
- MessagePack encodes tick 128 as -128 — off-by-one in signed int boundary checks (<=128 instead of <128) across int8/16/32/64 branches
- Monologue/dialogue lost on snapshot overwrite — one-shot events now carried forward when a newer snapshot replaces an unconsumed one
- Fog shader white screen on load failure — ColorRect defaults to transparent, shader load failure logged instead of crashing
- Fog desync during camera smooth pan — fog rect now tracks camera position instead of player position
### Changed
- Server game loop throttled to ~20 ticks/sec (50ms frames) — non-blocking TCP loop no longer spins; remaining frame budget available for NPC AI
- Hold-to-move input model — movement polled each frame with stance-based throttle (Sprint=200ms, Walk=400ms, Careful=600ms, Crouch=800ms) and composite diagonals (W+D → northeast)
- D-053 updated with client throttle rates and input model documentation
### Added
- Dialogue box UI skeleton (#434, D-061) — bottom screen, max 20% height, ~65% width, NPC speech + max 3 response options, insert-styled colors, WASD walk-away with 300ms fade, no close button, diegetic on InsertOverlay z-layer 6
- Fog entity visualization (#431, D-059/D-060) — cognitive delay rendering: sonar-style sound pings (3 concentric rings, 1.5s fade), unrecognized grey blobs with 0.8s breathing pulse, D-033 color transition at 50% recognition progress, ±0.5 tile position drift, FogEntities node at z:950
### Changed
- Client protocol version bumped from 6 to 7 (pending_recognitions decode for cognitive delay)
- GameState: current_dialogue and pending_recognitions fields wired from ObserverSnapshot v7
- Scene tree: DialogueBox added to InsertOverlay, FogEntities at z:950 between fog shader and InsertOverlay
- Test mode: mock dialogue (Kael NPC, 3 options) and mock cognitive delay entity (6-tick recognition cycle)
### Added
- Archetype evidence presentation spec (#443, D-065/D-034/D-033) — detective case file vs smuggler notebook design document: item definitions, knowledge graph presentation, contradiction markers, THE FRIEND arc walkthroughs, systems interaction map, authoring guidelines
- Cognitive delay system (#423, D-060) — CognitiveDelay component buffers perception events before emitting KnowledgeEvents (0.6s base / 0.3s urgent at 10 tps), pending_recognitions in ObserverSnapshot v7 for client fog entity visualization, cancellation on entity LOS exit
- ListeningFocus eavesdrop system (#426, D-053) — stationary_ticks tracking for eavesdrop positioning bonus, 30-tick threshold (20 for Careful stance), Sprint blocks accumulation, registered after validate_movement
- YAML content loader with hot-reload (#326, D-028) — LinePool system parsing dialogue/monologue YAML into BTreeMap-indexed pools, 4-layer query filtering (access > situation > trust > topic+mood), timestamp-polling hot-reload (dev-only), graceful failure preserves previous content
- Line pool format specification (#308) — formal spec at docs/architecture/line-pool-format.md defining YAML structure, tag enums, 4-layer filtering pipeline, prerequisite-to-KG mapping, ID format, and Rust loader interface
- InteractionMemory KG schema design (#442, D-064) — design doc at docs/architecture/interaction-memory-schema.md extending FactKnowledge with interaction tracking, 5-state InteractionState enum, monologue prerequisite extension, NpcTolerance reconciliation
- Audio discussion decisions D-067 through D-074: recognition chime timing, 5-bus architecture, audio dip profiles, confrontation as cognitive vulnerability, monologue chime placeholder strategy, universal conversation murmur, zone crossfade, hybrid audio generation
- Asset pipeline documentation system (docs/assets/) with category-based index, sonic palette, and generation templates for audio, visual, and video pipelines
- Stable Audio Open connector and post-processing wrappers (audio-generate, audio-health, audio-post) with timeout handling for 11GB VRAM constraint
- gen-audio skill with prompt assembly system (sonic palette prefixes + category templates + asset descriptions)
- 6 interaction UI audio assets (#440): cursor_hover, weapon_aim, implant_open, fog_recognition, sfx_monologue_chime, sfx_monologue_chime_urgent — generated via SAO, needs duration trimming (#453)
- Dialogue/confrontation ambient dip implementation spec with full Godot AudioBus tween code
- Synthesis tooling (tooling/synth_ui_sounds.py) for programmatic insert-tech sound generation
### Changed
- Protocol version bumped from 6 to 7 (pending_recognitions field in ObserverSnapshot)
- MessagePack fixtures regenerated for protocol v7
- Monologue trigger system uses .values() iterator (clippy fix)
- Monologue schema: relationship prerequisite now requires target and state fields
- 389 tests total (70 new) — cognitive delay pipeline, line pool loader, ListeningFocus, content watching, serialization
- Renamed asset-gen skill to gen-image for consistent gen-* naming pattern
- Client protocol v6 bridge — decode player_stance (4 variants) and player_inventory from ObserverSnapshot, TOGGLE_STANCE_UP/DOWN input actions, 25 gdUnit4 tests
- Three-scope z-layer rendering pipeline (D-049) — world z:0-900 inside CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal CanvasLayer 30. Y-sort contract enforced, reserved VFX/airborne/lower-floor ranges documented
- Cursor state machine (#429, D-056) — 4 states (Default/EntityHover/ObjectHover/WeaponAim), 150ms transitions, insert-styled geometric shapes
+125 -4
View File
@@ -2,7 +2,11 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content content-ron check-fact-ids setup-hooks
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
# --- Configuration ---
@@ -35,6 +39,16 @@ help:
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make content-ron Convert content YAML to RON (build-time)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@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 ""
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
@echo ""
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
@echo ""
@@ -56,6 +70,8 @@ setup-tooling:
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; }
@command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; }
@command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; }
@python3 -c "import yaml" 2>/dev/null || { echo "Install PyYAML: pip install pyyaml"; exit 1; }
@python3 -c "import jsonschema" 2>/dev/null || { echo "Install jsonschema: pip install jsonschema"; exit 1; }
setup-hooks:
@git config core.hooksPath .config/hooks
@@ -70,7 +86,7 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client --quit
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -81,7 +97,7 @@ client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --path client
game: stop build-server
game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@@ -104,6 +120,41 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
$(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd
@echo "Verifying Rust can decode GDScript fixtures..."
cd server && cargo test --test serialization gdscript_generated_fixtures
@echo "--- Fixtures-client: PASS ---"
golden-diff:
@echo "Regenerating golden output for comparison..."
@mkdir -p .cache/golden
@cp server/tests/golden/proof_room_tick_10.json .cache/golden/before.json
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden; \
rc=$$?; \
cp ../server/tests/golden/proof_room_tick_10.json ../.cache/golden/after.json 2>/dev/null; \
cp ../.cache/golden/before.json ../server/tests/golden/proof_room_tick_10.json; \
if [ $$rc -ne 0 ]; then echo "Cargo test failed (golden file restored)."; exit $$rc; fi
@if diff -q .cache/golden/before.json .cache/golden/after.json >/dev/null 2>&1; then \
echo "--- Golden file: UP TO DATE ---"; \
else \
echo "--- Golden file has CHANGED ---"; \
echo ""; \
diff --color -u .cache/golden/before.json .cache/golden/after.json || true; \
echo ""; \
echo "Run 'make golden-update' to accept changes."; \
exit 1; \
fi
golden-update:
@echo "Regenerating golden file..."
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
@git add server/tests/golden/
@echo "--- Golden file updated and staged ---"
@echo "Review with: git diff --cached -- server/tests/golden/"
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
@@ -121,6 +172,70 @@ lint-client:
@echo "Checking GDScript for errors..."
@$(GODOT) --headless --path client --quit 2>&1 | grep -i "SCRIPT ERROR" && { echo "GDScript errors found"; exit 1; } || echo "No script errors found"
# --- Pre-PR verification ---
pre-pr: pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures
@echo ""
@echo "=== PRE-PR: ALL CHECKS PASSED ==="
@echo "Safe to create PR."
pre-pr-lint: lint-server lint-client
@echo "--- Lint: PASS ---"
pre-pr-build: build-server build-client
@echo "--- Build: PASS ---"
pre-pr-test: test-server test-client
@echo "--- Tests: PASS ---"
pre-pr-validate: validate-content check-fact-ids
@echo "--- Content validation: PASS ---"
pre-pr-fixtures:
@echo "Checking Rust->GDScript fixture staleness..."
@cd server && cargo test --test gen_fixtures -- --ignored
@if git diff --quiet client/tests/fixtures/; then \
echo "--- Rust fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- RUST FIXTURES STALE ---"; \
echo " Protocol changed but fixtures not regenerated."; \
echo " Stale fixtures make all client tests FALSE POSITIVES."; \
echo ""; \
echo " Changed files:"; \
git diff --stat client/tests/fixtures/; \
echo ""; \
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
@echo "Checking GDScript->Rust fixture staleness..."
@$(MAKE) fixtures-client
@if git diff --quiet server/tests/fixtures/gdscript/; then \
echo "--- GDScript fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- GDSCRIPT FIXTURES STALE ---"; \
echo " Protocol changed but GDScript fixtures not regenerated."; \
echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \
echo ""; \
echo " Changed files:"; \
git diff --stat server/tests/fixtures/gdscript/; \
echo ""; \
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
# Branch-specific variants (faster, scope-appropriate)
pre-pr-server: lint-server build-server test-server pre-pr-fixtures
@echo "=== Server pre-PR: PASSED ==="
pre-pr-client: lint-client build-client test-client
@echo "=== Client pre-PR: PASSED ==="
pre-pr-content: validate-content check-fact-ids checklist-validate
@echo "=== Content pre-PR: PASSED ==="
# --- CI (run locally) ---
ci: ci-server ci-client
@@ -159,6 +274,12 @@ validate-content:
check-fact-ids:
@tooling/check-fact-ids
checklist-validate:
@tooling/validate-checklist --check
checklist-generate:
@tooling/validate-checklist
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -168,5 +289,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot client/reports
rm -rf client/.godot/* client/reports
@echo "Clean complete."
+15 -10
View File
@@ -69,25 +69,26 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
TYPE_INT:
if - (1 << 5) <= value and value <= (1 << 7) - 1:
buffer.put_8(value)
elif - (1 << 7) <= value and value <= (1 << 7):
elif - (1 << 7) <= value and value < (1 << 7):
buffer.put_u8(types["int_8"])
buffer.put_8(value)
elif 0 <= value and value <= (1 << 8) - 1:
buffer.put_u8(types["uint_8"])
buffer.put_u8(value)
elif - (1 << 15) <= value and value <= (1 << 15):
elif - (1 << 15) <= value and value < (1 << 15):
buffer.put_u8(types["int_16"])
buffer.put_16(value)
elif 0 <= value and value <= (1 << 16) - 1:
buffer.put_u8(types["uint_16"])
buffer.put_u16(value)
elif - (1 << 31) <= value and value <= (1 << 31):
elif - (1 << 31) <= value and value < (1 << 31):
buffer.put_u8(types["int_32"])
buffer.put_32(value)
elif 0 <= value and value <= (1 << 32) - 1:
buffer.put_u8(types["uint_32"])
buffer.put_u32(value)
elif - (1 << 63) <= value and value <= (1 << 63):
elif value < 0:
# Negative beyond int_32 range — encode as int_64 (0xd3)
buffer.put_u8(types["int_64"])
buffer.put_64(value)
else:
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for obj in value:
_encode_message(buffer, obj)
var inner_err = _encode_message(buffer, obj)
if inner_err:
return inner_err
TYPE_DICTIONARY:
var size = value.size()
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for key in value:
_encode_message(buffer, key)
_encode_message(buffer, value[key])
var key_err = _encode_message(buffer, key)
if key_err:
return key_err
var val_err = _encode_message(buffer, value[key])
if val_err:
return val_err
TYPE_PACKED_BYTE_ARRAY:
var size = value.size()
@@ -225,11 +232,9 @@ static func _decode_message(buffer: StreamPeerBuffer, err: Dictionary):
return buffer.get_utf8_string(size)
elif first_byte == types["nil"]: # nil
print("null size:%s"%buffer_size)
return null
elif first_byte == types["false"]: # false
print("false size:%s"%buffer_size)
return false
elif first_byte == types["true"]: # true
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://uflbvtm1aquk"
path="res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"
[deps]
source_file="res://assets/audio/cursor_hover.ogg"
dest_files=["res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://bw0mfil2343po"
path="res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"
[deps]
source_file="res://assets/audio/fog_recognition.ogg"
dest_files=["res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dei8ikwaqyn67"
path="res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"
[deps]
source_file="res://assets/audio/implant_open.ogg"
dest_files=["res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b7iopmg6ftp4k"
path="res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_monologue_chime.ogg"
dest_files=["res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://4m87qby6qdhc"
path="res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_monologue_chime_urgent.ogg"
dest_files=["res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dbu2lr4scswyn"
path="res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_stance_change.ogg"
dest_files=["res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c5lduxqgx0vme"
path="res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_weapon_aim_lock.ogg"
dest_files=["res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c2bqso5hkljq1"
path="res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"
[deps]
source_file="res://assets/audio/weapon_aim.ogg"
dest_files=["res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+8
View File
@@ -144,6 +144,14 @@ tutorial_prompts:
minimap_hint: "The overlay shows the area around you."
knowledge_hint: "Open your notes to review what you know."
# ============================================================
# DIALOGUE
# Text displayed during dialogue interactions.
# D-063: Confrontation beat monologue — character's internal hesitation.
# ============================================================
dialogue:
confrontation_beat: "This changes things. No taking it back."
# ============================================================
# MENU AND SYSTEM TEXT
# Non-diegetic. Standard game UI. Clean, no flavor text.
+6
View File
@@ -22,6 +22,7 @@ GameState="*res://scripts/autoloads/game_state.gd"
InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[display]
@@ -105,6 +106,11 @@ stance_down={
"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":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
bug_report={
"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":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+21 -2
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=15 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=19 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"]
@@ -14,6 +14,10 @@
[ext_resource type="PackedScene" path="res://ui/inventory_grid.tscn" id="12_inv"]
[ext_resource type="PackedScene" path="res://ui/stance_indicator.tscn" id="13_stance"]
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -84,6 +88,13 @@ z_index = 300
z_index = 900
script = ExtResource("4_fog")
; --- z:950 — Fog entities (D-059/D-060 cognitive delay visualization) ---
; Between fog (z:900) and InsertOverlay (CanvasLayer 10).
; Sound pings, grey blobs, recognized entity glow + silhouette.
[node name="FogEntities" type="Node2D" parent="World"]
z_index = 950
script = ExtResource("16_fogent")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
@@ -105,6 +116,9 @@ layer = 10
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" parent="InsertOverlay" instance=ExtResource("14_radial")]
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
@@ -119,6 +133,9 @@ layer = 20
; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -128,6 +145,8 @@ script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
[node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
+314
View File
@@ -0,0 +1,314 @@
extends Node
## AudioManager autoload singleton (D-068, D-069, D-073).
## 5-bus architecture with directory-scan registry pattern.
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
# --- Bus names (D-068) ---
const BUS_MUSIC := "Music"
const BUS_AMBIENT := "Ambient"
const BUS_WORLD_SFX := "WorldSFX"
const BUS_PLAYER_ACTIONS := "PlayerActions"
const BUS_UI_SOUNDS := "UISounds"
const BUSES := [BUS_MUSIC, BUS_AMBIENT, BUS_WORLD_SFX, BUS_PLAYER_ACTIONS, BUS_UI_SOUNDS]
# D-069: Audio dip profiles — dB offsets relative to player slider setting.
# Mid-range of spec values used (e.g. -6 to -8 → -7).
# NOTE: listening_focus requires 30+ stationary ticks before activation (D-069/D-071).
# The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
const DIP_SPECS := {
"dialogue": {
"ease_in": 0.3, "ease_out": 0.5,
"buses": { "Ambient": -7.0 },
},
"confrontation": {
"ease_in": 0.5, "ease_out": 1.0,
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 },
"filter_hz": 800.0,
},
"listening_focus": {
"ease_in": 0.5, "ease_out": 0.5,
"buses": { "WorldSFX": 2.5 },
},
}
# Low-pass filter default cutoff — effectively bypassed at this value.
const FILTER_CUTOFF_DEFAULT := 20500.0
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
# Player volume settings per bus (dB). Base for proportional dip calculation.
var _bus_volumes: Dictionary = {}
# Active dip state (D-069)
var _active_dip: String = ""
var _dip_tweens: Array = []
# Low-pass filter on Ambient bus (D-069 confrontation sweep)
var _ambient_filter: AudioEffectLowPassFilter = null
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
var _ambient_players: Dictionary = {}
signal dip_changed(profile: String)
func _ready() -> void:
_setup_buses()
_scan_registry()
# --- Bus setup ---
func _setup_buses() -> void:
for bus_name in BUSES:
if AudioServer.get_bus_index(bus_name) < 0:
AudioServer.add_bus()
var idx := AudioServer.bus_count - 1
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, "Master")
_bus_volumes[bus_name] = 0.0
# Low-pass filter on Ambient bus — cutoff starts high (bypassed).
# Confrontation dip sweeps it down to 800 Hz (D-069).
# Guard: skip if filter already exists (e.g. duplicate _ready in tests).
var amb_idx := AudioServer.get_bus_index(BUS_AMBIENT)
if amb_idx >= 0 and _ambient_filter == null:
_ambient_filter = AudioEffectLowPassFilter.new()
_ambient_filter.cutoff_hz = FILTER_CUTOFF_DEFAULT
AudioServer.add_bus_effect(amb_idx, _ambient_filter)
# --- Asset registry (D-068 directory-scan pattern) ---
func _scan_registry() -> void:
_scan_dir("res://audio/")
print("AudioManager: %d assets registered" % _registry.size())
func _scan_dir(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
if path == "res://audio/":
print("AudioManager: res://audio/ not found — all play methods no-op")
return
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_scan_dir(full_path)
elif not file_name.ends_with(".import"):
if file_name.get_extension().to_lower() in ["ogg", "wav", "mp3"]:
var stream := load(full_path) as AudioStream
if stream:
_registry[file_name.get_basename()] = stream
file_name = dir.get_next()
func has_asset(asset_key: String) -> bool:
return _registry.has(asset_key)
func get_registry_size() -> int:
return _registry.size()
# --- Playback: non-spatial ---
## Play a one-shot sound on the given bus. No-ops if asset not in registry.
func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
var stream := _get_stream(asset_key)
if stream == null:
return
var player := AudioStreamPlayer.new()
player.stream = stream
player.bus = bus
add_child(player)
player.finished.connect(player.queue_free)
player.play()
## Start or replace a looping non-spatial sound. Returns the player or null.
func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlayer:
var stream := _get_stream(asset_key)
if stream == null:
return null
stop_loop(asset_key)
var loop_stream := stream.duplicate() as AudioStream
_enable_loop(loop_stream)
var player := AudioStreamPlayer.new()
player.stream = loop_stream
player.bus = bus
add_child(player)
player.play()
_ambient_players[asset_key] = player
return player
## Stop a looping sound by asset key.
func stop_loop(asset_key: String) -> void:
if _ambient_players.has(asset_key):
var player: AudioStreamPlayer = _ambient_players[asset_key]
if is_instance_valid(player):
player.stop()
player.queue_free()
_ambient_players.erase(asset_key)
## Stop all looping sounds.
func stop_all_loops() -> void:
for key in _ambient_players.keys():
stop_loop(key)
# --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels).
## Added to current_scene for correct AudioListener2D positioning.
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void:
var stream := _get_stream(asset_key)
if stream == null:
return
var player := AudioStreamPlayer2D.new()
player.stream = stream
player.bus = bus
player.global_position = world_position
var scene := get_tree().current_scene
if scene:
scene.add_child(player)
else:
add_child(player)
player.finished.connect(player.queue_free)
player.play()
# --- Audio dip profiles (D-069) ---
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
## Profiles: "dialogue", "confrontation", "listening_focus"
func apply_dip(profile: String) -> void:
if not DIP_SPECS.has(profile):
push_warning("AudioManager: unknown dip profile '%s'" % profile)
return
_kill_tweens()
_active_dip = profile
var spec: Dictionary = DIP_SPECS[profile]
var buses: Dictionary = spec.get("buses", {})
var ease_in: float = spec.get("ease_in", 0.3)
# Tween ALL buses — affected ones get offset, unaffected ones restore to base.
for bus_name in BUSES:
var offset_db: float = buses.get(bus_name, 0.0)
var target_db: float = _bus_volumes.get(bus_name, 0.0) + offset_db
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
continue
var current_db := AudioServer.get_bus_volume_db(idx)
if absf(current_db - target_db) < 0.1:
continue
var tween := create_tween()
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_in)
_dip_tweens.append(tween)
# Confrontation: sweep low-pass filter on Ambient (D-069)
if spec.has("filter_hz") and _ambient_filter:
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", spec["filter_hz"], ease_in)
_dip_tweens.append(tween)
elif _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
# Leaving confrontation for another dip — restore filter
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_in)
_dip_tweens.append(tween)
dip_changed.emit(profile)
## Clear the active dip, restoring all bus volumes to player slider settings.
func clear_dip() -> void:
if _active_dip.is_empty():
return
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
var ease_out: float = spec.get("ease_out", 0.5)
_kill_tweens()
_active_dip = ""
for bus_name in BUSES:
var target_db: float = _bus_volumes.get(bus_name, 0.0)
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
continue
var current_db := AudioServer.get_bus_volume_db(idx)
if absf(current_db - target_db) < 0.1:
continue
var tween := create_tween()
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_out)
_dip_tweens.append(tween)
# Restore low-pass filter
if _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
var tween := create_tween()
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_out)
_dip_tweens.append(tween)
dip_changed.emit("")
func get_active_dip() -> String:
return _active_dip
# --- Volume control (player sliders, D-069 proportional base) ---
## Set the player's volume preference for a bus. During active dip, immediately
## recalculates effective volume (proportional to new base).
func set_volume(bus: String, volume_db: float) -> void:
_bus_volumes[bus] = volume_db
var idx := AudioServer.get_bus_index(bus)
if idx < 0:
return
if _active_dip.is_empty():
AudioServer.set_bus_volume_db(idx, volume_db)
else:
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
var buses: Dictionary = spec.get("buses", {})
var offset_db: float = buses.get(bus, 0.0)
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
func get_volume(bus: String) -> float:
return _bus_volumes.get(bus, 0.0)
# --- Zone crossfade (D-073 stub) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Full crossfade implementation deferred to Sprint 9+ (D-073).
## Stub exists so server integration can call it without conditional checks.
func set_zone(_zone_id: String) -> void:
pass
# --- Internal helpers ---
func _get_stream(asset_key: String) -> AudioStream:
return _registry.get(asset_key) as AudioStream
func _make_bus_setter(bus_name: String) -> Callable:
return func(db: float) -> void:
var idx := AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, db)
func _kill_tweens() -> void:
for tween in _dip_tweens:
if tween != null and tween.is_valid():
tween.kill()
_dip_tweens.clear()
static func _enable_loop(stream: AudioStream) -> void:
if stream is AudioStreamOggVorbis:
stream.loop = true
elif stream is AudioStreamWAV:
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
elif stream is AudioStreamMP3:
stream.loop = true
@@ -0,0 +1 @@
uid://cdkut8u7sxwmb
+20 -9
View File
@@ -4,6 +4,17 @@ extends Node
## Read by fog_shader.gd for shader uniforms. Not a renderer — pure data.
## Architecture: docs/architecture/fog-shader-spec.md | D-059
# Fog texture byte values — visibility and exploration layers.
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
@@ -31,13 +42,13 @@ func _resize(bounds: Rect2i) -> void:
_vis_bytes = PackedByteArray()
_vis_bytes.resize(sz)
_vis_bytes.fill(0)
_vis_bytes.fill(VIS_HIDDEN)
_vis_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture = ImageTexture.create_from_image(_vis_image)
_exp_bytes = PackedByteArray()
_exp_bytes.resize(sz)
_exp_bytes.fill(0)
_exp_bytes.fill(EXP_UNEXPLORED)
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
@@ -62,21 +73,21 @@ func update_from_state() -> void:
var positions: Dictionary = GameState.visible_positions
var sectors: Dictionary = GameState.visibility_sectors
# TODO(v0.2): gradual decay over game-time instead of immediate 255→128
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
# 1. Clear visibility, then write current LOS
_vis_bytes.fill(0)
_vis_bytes.fill(VIS_HIDDEN)
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var sector: String = sectors.get(pos, "Forward")
_vis_bytes[py * _width + px] = 255 if sector == "Forward" else 180
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
# 2. Exploration: tiles leaving LOS decay to 128, visible tiles stay 255
# 2. Exploration: tiles leaving LOS decay to EXP_EXPLORED, visible tiles stay EXP_VISIBLE
# Only touch tiles that changed (O(visible) not O(map_size))
for pos in _prev_visible:
if not positions.has(pos):
@@ -84,14 +95,14 @@ func update_from_state() -> void:
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
var idx: int = py * _width + px
if _exp_bytes[idx] > 128:
_exp_bytes[idx] = 128
if _exp_bytes[idx] > EXP_EXPLORED:
_exp_bytes[idx] = EXP_EXPLORED
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
_exp_bytes[py * _width + px] = 255
_exp_bytes[py * _width + px] = EXP_VISIBLE
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
@@ -0,0 +1 @@
uid://de816lfn5xeqs
+36
View File
@@ -29,6 +29,20 @@ var current_monologue: Variant = null # {id, text, duration_seconds} or null
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
# v7 fields (#435, D-061/D-062)
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null
# D-064: true while dialogue box is visible or fading out (300ms).
# InputMapper suppresses movement when this is true.
var dialogue_active: bool = false
# v8 fields (#496): Gauntlet mode — room timer + personal bests
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
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -96,6 +110,28 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
player_inventory = []
# v7: current_dialogue (#434, D-061)
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
current_dialogue = snapshot.current_dialogue
else:
current_dialogue = null
# v7: pending_recognitions (#431, D-059/D-060)
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
pending_recognitions = snapshot.pending_recognitions
else:
pending_recognitions = []
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
gauntlet_mode = true
else:
gauntlet_mode = false
if snapshot.has("room_id") and snapshot.room_id is String:
room_id = snapshot.room_id
else:
room_id = 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:
+72 -25
View File
@@ -1,60 +1,107 @@
extends Node
# Semantic actions — NO raw key codes cross the bridge
# Diagonal directions registered in project.godot with empty event arrays (intentional).
# Keybindings deferred until input design is finalized — likely numpad or composite WASD.
# 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.
#
# 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.
# Server cooldown is authoritative, but the client throttle prevents
# flooding and gives correct movement feel in test mode.
enum Action {
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE,
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
}
var input_queue: Array[Dictionary] = []
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
"Sprint": 200, # 5/sec — fast but trackable
"Walk": 400, # 2.5/sec — comfortable walking pace
"Careful": 600, # ~1.7/sec — deliberate, scanning
"Crouch": 800, # 1.25/sec — creeping
}
var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# 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:
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:
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)
input_queue.append({
"action": action,
"timestamp_msec": now,
})
# Discrete actions: fire once on key press (not held).
func _unhandled_input(event: InputEvent) -> void:
var action: Action = -1
# Map input actions to semantic Action enum
# is_action_pressed handles press detection for all input types (key, gamepad, etc.)
if event.is_action_pressed("move_north"):
action = Action.MOVE_NORTH
elif event.is_action_pressed("move_northeast"):
action = Action.MOVE_NORTHEAST
elif event.is_action_pressed("move_east"):
action = Action.MOVE_EAST
elif event.is_action_pressed("move_southeast"):
action = Action.MOVE_SOUTHEAST
elif event.is_action_pressed("move_south"):
action = Action.MOVE_SOUTH
elif event.is_action_pressed("move_southwest"):
action = Action.MOVE_SOUTHWEST
elif event.is_action_pressed("move_west"):
action = Action.MOVE_WEST
elif event.is_action_pressed("move_northwest"):
action = Action.MOVE_NORTHWEST
elif event.is_action_pressed("interact"):
if event.is_action_pressed("interact"):
action = Action.INTERACT
elif event.is_action_pressed("perception_mode"):
action = Action.USE_PERCEPTION_MODE
elif event.is_action_pressed("open_menu"):
action = Action.OPEN_MENU
elif event.is_action_pressed("pause"):
action = Action.PAUSE
var tick_rate = GameState.game_time.get("tick_rate", "Full")
action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE
elif event.is_action_pressed("stance_up"):
action = Action.TOGGLE_STANCE_UP
elif event.is_action_pressed("stance_down"):
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
# Queue the action if valid
if action != -1:
input_queue.append({
"action": action,
"timestamp_msec": Time.get_ticks_msec()
"timestamp_msec": Time.get_ticks_msec(),
})
get_viewport().set_input_as_handled()
func flush_queue() -> Array[Dictionary]:
var queue = input_queue.duplicate()
input_queue.clear()
return queue
# 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:
match dir:
Vector2i(0, -1): return Action.MOVE_NORTH
Vector2i(1, -1): return Action.MOVE_NORTHEAST
Vector2i(1, 0): return Action.MOVE_EAST
Vector2i(1, 1): return Action.MOVE_SOUTHEAST
Vector2i(0, 1): return Action.MOVE_SOUTH
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST
Vector2i(-1, 0): return Action.MOVE_WEST
Vector2i(-1, -1): return Action.MOVE_NORTHWEST
_: return Action.MOVE_NORTH
+65 -9
View File
@@ -9,6 +9,7 @@ var _test_tick: int = 0
var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -38,6 +39,7 @@ func reset_test_state() -> void:
_test_player_pos = Vector2i(10, 10)
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -212,14 +214,21 @@ func poll_snapshot() -> Variant:
return null
# Called by transport layer when raw bytes arrive from the server.
# Latest-wins semantics: newer snapshots replace unconsumed ones. This is correct
# for real-time rendering (stale frames are worthless). Upgrade to queue if needed.
# Latest-wins for positional state (stale frames are worthless), but one-shot
# events (monologue, dialogue) are carried forward from overwritten snapshots
# so they aren't silently dropped when server ticks faster than client consumes.
func receive_bytes(bytes: PackedByteArray) -> void:
var snapshot = Protocol.decode_snapshot(bytes)
if snapshot != null:
if _last_snapshot != null:
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
_last_snapshot = snapshot
if snapshot == null:
push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size())
return
if _last_snapshot != null:
# Carry forward one-shot events the client hasn't consumed yet.
if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null:
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
func drain_outbound() -> Array[Dictionary]:
@@ -242,12 +251,13 @@ static func _action_enum_to_wire(action: int) -> String:
InputMapper.Action.INTERACT: return "Interact"
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
InputMapper.Action.PAUSE: return "Pause"
InputMapper.Action.UNPAUSE: return "Unpause"
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
return ""
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -263,12 +273,22 @@ func _test_snapshot() -> Dictionary:
# Process queued inputs
for action_name in _test_input_queue:
if action_name == "Interact":
# Mock dialogue trigger (#434): if near NPC, start dialogue
var npc_pos := Vector2i(12, 9)
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
_test_in_dialogue = true
continue
var delta := _action_to_delta(action_name)
var new_pos := _test_player_pos + delta
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
_test_input_queue.clear()
var px := _test_player_pos.x
@@ -320,6 +340,40 @@ func _test_snapshot() -> Dictionary:
"duration_seconds": 5.0,
}
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
# Movement (walk-away) clears it. Client consume-once guards against re-show.
# Options: structured {text, response_id, priority} per #435.
var dialogue: Variant = null
if _test_in_dialogue:
dialogue = {
"npc_name": "Kael",
"npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
"options": [
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
],
}
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
var pending_recs: Array = []
var cycle_pos := _test_tick % 12
if cycle_pos < 6:
var total_delay := 6
var remaining := total_delay - cycle_pos
pending_recs.append({
"entity_id": 100,
"x": 13.5,
"y": 12.5,
"z": 0,
"remaining_ticks": remaining,
"total_delay_ticks": total_delay,
})
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -338,6 +392,8 @@ func _test_snapshot() -> Dictionary:
"visible_positions": _test_visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+7
View File
@@ -26,6 +26,7 @@ const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alp
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
# z:500-899 reserved: edge cases
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
# z:-75 to z:-51 reserved: lower floor VFX
#
@@ -67,6 +68,12 @@ static func color_for_entity_kind(entity_data: Dictionary) -> Color:
"Object", "Terrain": return ENTITY_COLOR_OBJECT
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
# D-015: Peripheral vision dimming
const PERIPHERAL_ALPHA: float = 0.5
+153 -11
View File
@@ -1,35 +1,86 @@
extends Node2D
@onready var world_renderer = $World
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
@onready var camera = $Camera2D
@onready var hud = $UILayer/HUD
@onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
@onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@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 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
func _ready() -> void:
print("The Settled Reach — client initialized")
# Connect to simulation (will use test mode initially)
# Disable camera smoothing during init. Camera2D's position_smoothing
# lerps an internal smoothed_camera_pos toward global_position each frame.
# That smoothed position initializes at (0,0) — the Camera2D's default in
# the .tscn. Even after we set global_position to the player coords,
# smoothing causes the viewport to still show (0,0) on the first rendered
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
# uses global_position directly. Re-enabled in _process() after anchor.
camera.position_smoothing_enabled = false
# Connect to simulation (test mode sets CONNECTED immediately)
SimBridge.connect_to_sim()
# Camera anchor: snap to player position before the first frame renders.
# In test mode poll_snapshot() returns synchronously — position is set
# immediately. In live mode the snapshot isn't available yet — _process
# handles it. No reset_smoothing() needed: smoothing is OFF.
var first_snapshot: Variant = SimBridge.poll_snapshot()
if first_snapshot != null:
GameState.apply_snapshot(first_snapshot)
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# D-061: Connect dialogue box signals
if dialogue_box:
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot = SimBridge.poll_snapshot()
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
GameState.apply_snapshot(snapshot)
# 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.
if not _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# 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"):
interaction_list.update_from_state()
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list._hide()
else:
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
@@ -39,21 +90,40 @@ func _process(_delta: float) -> void:
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# D-059/D-060: Update fog entity visualization (#431)
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# Show monologue if server sent one this tick (#414)
# Consume-once: set to null after showing to prevent re-display.
# Single monologue per snapshot is guaranteed by server.
if GameState.current_monologue != null and monologue_display:
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# Track camera to player position every frame (D-015: locked, no panning)
# Camera2D smoothing handles interpolation — we just set the target
camera.global_position = GameState.player_position * Constants.TILE_SIZE
if _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
# Re-enable smoothing after the first anchored frame. The frame that just
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
if input.action == InputMapper.Action.BUG_REPORT:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -76,3 +146,75 @@ func _process(_delta: float) -> void:
"verb": null,
}
SimBridge.send_input(input)
# Consume-once per tick: show monologue text, then clear.
# Tick guard prevents re-triggering when the same tick is polled multiple
# times (client FPS > sim tick rate).
func _consume_monologue() -> void:
if GameState.current_monologue == null or not monologue_display:
return
if GameState.current_tick == _last_monologue_tick:
return
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null
# Consume-once per tick with ID tracking: show dialogue, then clear.
# Tick guard + is_dialogue_active check prevent re-triggering.
func _consume_dialogue() -> void:
if GameState.current_dialogue == null or not dialogue_box:
return
if GameState.current_tick == _last_dialogue_tick:
return
if dialogue_box.is_dialogue_active():
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
dialogue_box.show_dialogue(
dlg.get("npc_name", ""),
dlg.get("speech", ""),
dlg.get("options", [])
)
GameState.current_dialogue = null
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": null,
"verb": "DialogueResponse",
"response_id": response_id,
},
})
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
func _on_confrontation_monologue(text: String, duration: float) -> void:
if monologue_display:
monologue_display.show_monologue(text, duration)
# D-064: Handle walk-away → send WalkAway{npc_id} to server
func _on_dialogue_dismissed() -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
"verb": "WalkAway",
},
})
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize()
+76 -14
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 6
const PROTOCOL_VERSION: int = 8
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -122,6 +122,64 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"slot": int(raw_item.get("slot", 0)),
})
# v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities
# Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit
# given perception range). Excessive arrays are truncated to prevent allocation abuse.
const MAX_PENDING_RECOGNITIONS: int = 64
var pending_recognitions: Array = []
var raw_recognitions: Variant = raw.get("pending_recognitions")
if raw_recognitions is Array:
var count := 0
for raw_pr in raw_recognitions:
if count >= MAX_PENDING_RECOGNITIONS:
push_warning("Protocol: pending_recognitions truncated at %d entries" % MAX_PENDING_RECOGNITIONS)
break
if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"):
pending_recognitions.append({
"entity_id": int(raw_pr["entity_id"]),
"x": float(raw_pr["x"]),
"y": float(raw_pr["y"]),
"z": int(raw_pr.get("z", 0)),
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
})
count += 1
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options
# Options carry response_id for server round-trip and priority for display ordering.
# Locked options are invisible (D-062): server filters before sending.
var current_dialogue: Variant = null
var raw_dialogue: Variant = raw.get("current_dialogue")
if raw_dialogue is Dictionary and raw_dialogue.has("speech"):
var dialogue_options: Array = []
var raw_options: Variant = raw_dialogue.get("options")
if raw_options is Array:
for raw_opt in raw_options:
if raw_opt is Dictionary and raw_opt.has("text"):
dialogue_options.append({
"text": str(raw_opt["text"]),
"response_id": str(raw_opt.get("response_id", "")),
"priority": int(raw_opt.get("priority", 0)),
"confrontation": bool(raw_opt.get("confrontation", false)),
})
current_dialogue = {
"npc_name": str(raw_dialogue.get("npc_name", "")),
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)),
"speech": str(raw_dialogue["speech"]),
"options": dialogue_options,
}
# v8: dialogue_response (#305, D-028) — NPC's spoken dialogue line from server
# Separate from current_dialogue (client-side rich dialogue state).
var dialogue_response: Variant = null
var raw_dr: Variant = raw.get("dialogue_response")
if raw_dr is Dictionary and raw_dr.has("text"):
dialogue_response = {
"line_id": str(raw_dr.get("line_id", "")),
"text": str(raw_dr["text"]),
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
return {
"tick": tick,
"entities": entities,
@@ -134,6 +192,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
"current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions,
}
@@ -244,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action_value: Variant
if action_data != null:
# Data variant → single-element map
action_value = { action_name: action_data }
else:
# Unit variant → bare string
action_value = action_name
var action_value: Variant = _encode_action(action_name, action_data)
var input := {
"tick": tick,
@@ -273,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action_value: Variant
if action_data != null:
action_value = { action_name: action_data }
else:
action_value = action_name
wire_inputs.append({
"tick": input["tick"],
"action": action_value,
"action": _encode_action(action_name, action_data),
})
var result = Messagepack.encode(wire_inputs)
@@ -291,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
return result.value
## Encode a PlayerAction for the wire.
## Struct variants (Interact) always need their fields even when null.
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
if action_data != null:
return { action_name: action_data }
# Interact is a struct variant — server expects named fields, not a bare string
if action_name == "Interact":
return { "Interact": { "target_entity_id": null, "verb": null } }
return action_name
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant:
+2 -2
View File
@@ -214,8 +214,8 @@ func _draw() -> void:
func _draw_ticks(color: Color, thickness: float) -> void:
var dirs := [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
for dir in dirs:
var dirs: Array[Vector2] = [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
for dir: Vector2 in dirs:
var d := dir.rotated(_rot)
draw_line(d * _gap, d * (_gap + _len), color, thickness, true)
@@ -0,0 +1 @@
uid://bkbfcalfo1kk8
+38 -4
View File
@@ -5,6 +5,10 @@ extends Node2D
# Creates/updates/removes ColorRect children based on entity data
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
#
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
# The server moves entities in discrete tile steps; the lerp makes this look fluid.
# Speed is tuned so Sprint feels snappy and Walk/Careful/Crouch feel deliberate.
#
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
# color from RelationshipState via the knowledge graph.
@@ -12,11 +16,31 @@ const TILE_SIZE: int = Constants.TILE_SIZE
const ENTITY_SIZE: int = 24
const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile
var entity_nodes: Dictionary = {} # entity_id -> Node2D mapping
# Lerp speed — framerate-independent exponential smoothing.
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
func _ready() -> void:
print("EntityRenderer: Initialized")
func _process(delta: float) -> void:
# Lerp all entity visual positions toward their targets each frame.
# Uses framerate-independent exponential smoothing.
var weight := 1.0 - exp(-LERP_SPEED * delta)
for entity_id in entity_nodes.keys():
if not _entity_targets.has(entity_id):
continue
var node = entity_nodes[entity_id]
var target: Vector2 = _entity_targets[entity_id]
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
var active_ids: Array = []
@@ -62,19 +86,28 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if entity_id == GameState.player_entity_id:
_add_facing_indicator(entity_node)
# Snap to initial position (no lerp on first appearance)
if entity_data.has("x") and entity_data.has("y"):
var target := Vector2(
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
entity_node.position = target
_entity_targets[entity_id] = target
_update_entity_node(entity_id, entity_data)
# Update an existing entity node (position, visibility dimming, facing)
# Update an existing entity node (target position, visibility dimming, facing)
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
# Update position from x, y fields (Protocol format), centered within tile.
# Update target position — the lerp in _process() will smoothly move there.
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
if entity_data.has("x") and entity_data.has("y"):
entity_node.position = Vector2(
_entity_targets[entity_id] = Vector2(
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
@@ -100,6 +133,7 @@ func _remove_entity_node(entity_id: int) -> void:
var entity_node = entity_nodes[entity_id]
entity_node.queue_free()
entity_nodes.erase(entity_id)
_entity_targets.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:
+178
View File
@@ -0,0 +1,178 @@
class_name FogEntities
extends Node2D
## Fog entity visualization — renders entities undergoing cognitive delay recognition
## in the fog layer. Between FogOverlay (z:900) and InsertOverlay (CanvasLayer 10).
##
## Three visual states per D-059/D-060:
## 1. Sound pings: 2-3 thin concentric expanding rings (sonar-style)
## 2. Unrecognized entity: grey blob #555566, 0.8s breathing pulse
## 3. Recognized entity: D-033 color glow + silhouette + breathing pulse + position drift
##
## Cognitive delay transition: grey blob → color + silhouette over ~0.3s,
## driven by remaining_ticks / total_delay_ticks ratio from server.
##
## All drawing happens in _draw() — no child scripts needed.
const TILE_SIZE: int = Constants.TILE_SIZE
# D-059: Fog entity colors
const COLOR_UNRECOGNIZED := Color("#555566") # Neutral grey blob
# Intentionally matches Constants.INSERT_COLOR_TEXT / cursor default (#c8d0e0).
# Sonar pings are insert-generated — same visual language as cursor and HUD overlays.
const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-048/D-056)
# Animation timing
const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds)
const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing
const PULSE_MAX_ALPHA: float = 0.8 # Max alpha during breathing
const PING_DURATION: float = 1.5 # Sound ping expand + fade (seconds)
const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels)
const PING_RING_COUNT: int = 3 # Concentric rings per ping
const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels)
const DRIFT_RANGE: float = 0.5 # ±0.5 tile position drift (D-059)
const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander
# Entity blob rendering
const BLOB_RADIUS: float = 10.0
const SILHOUETTE_SCALE: float = 1.3 # Silhouette slightly larger than blob
# Transition thresholds: recognition progress mapped from remaining_ticks/total_delay_ticks.
# Color transition starts at 50% to compress the visual change into ~0.3s (D-060).
const COLOR_TRANSITION_START: float = 0.5
# Silhouette (body shape hint) appears after 80% progress — recognition nearly complete.
const SILHOUETTE_APPEAR_THRESHOLD: float = 0.3
# Silhouette size: approximate torso-sized rectangle in pixels at TILE_SIZE=32 scale.
const SILHOUETTE_SIZE := Vector2(6.0, 10.0)
# Internal state — no child nodes, pure data + _draw()
var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress}
var _pings: Array = [] # [{pos: Vector2, elapsed: float}]
var _time: float = 0.0
func _process(delta: float) -> void:
# Early return when idle — skip queue_redraw() when nothing to draw
if _entities.is_empty() and _pings.is_empty():
return
_time += delta
# Animate drifts
for eid in _entities.keys():
var e: Dictionary = _entities[eid]
e.drift_timer += delta
if e.drift_timer >= DRIFT_PERIOD:
e.drift_timer = 0.0
e.drift_offset = e.drift_target
e.drift_target = _random_drift()
var t: float = e.drift_timer / DRIFT_PERIOD
e.drift_offset = e.drift_offset.lerp(e.drift_target, t)
# Advance pings, remove expired
var i := _pings.size() - 1
while i >= 0:
_pings[i].elapsed += delta
if _pings[i].elapsed >= PING_DURATION:
_pings.remove_at(i)
i -= 1
queue_redraw()
## Called from main.gd each frame after GameState.apply_snapshot()
func update_from_state() -> void:
var recognitions: Array = GameState.pending_recognitions
var active_ids: Array = []
for rec in recognitions:
var eid: int = rec.entity_id
active_ids.append(eid)
if not _entities.has(eid):
# New entity — spawn with random drift and trigger sonar ping
_entities[eid] = {
"pos": Vector2(rec.x, rec.y),
"drift_offset": _random_drift(),
"drift_target": _random_drift(),
"drift_timer": 0.0,
"progress": 0.0,
}
_pings.append({"pos": Vector2(rec.x, rec.y), "elapsed": 0.0})
# Update server data
var e: Dictionary = _entities[eid]
e.pos = Vector2(rec.x, rec.y)
var total: int = rec.total_delay_ticks
var remaining: int = rec.remaining_ticks
if total > 0:
e.progress = clampf(1.0 - float(remaining) / float(total), 0.0, 1.0)
else:
e.progress = 1.0
# Remove entities no longer pending
var to_remove: Array = []
for eid in _entities.keys():
if eid not in active_ids:
to_remove.append(eid)
for eid in to_remove:
_entities.erase(eid)
func _draw() -> void:
# Breathing pulse — shared across all entities
var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD
var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA,
0.5 + 0.5 * sin(pulse_t * TAU))
# Draw entity blobs
for eid in _entities.keys():
var e: Dictionary = _entities[eid]
var world_pos: Vector2 = (e.pos + e.drift_offset) * TILE_SIZE
# Color transition: grey → D-033 teal based on recognition progress
# Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window)
var progress: float = e.progress
var color_t: float = clampf((progress - COLOR_TRANSITION_START) / (1.0 - COLOR_TRANSITION_START), 0.0, 1.0)
var blob_color: Color = COLOR_UNRECOGNIZED.lerp(Constants.ENTITY_COLOR_UNKNOWN, color_t)
blob_color.a = pulse_alpha
# Blob — unrecognized: plain circle. Recognized: larger glow + inner shape
if color_t < 1e-3:
# Pure unrecognized: grey blob, no silhouette (D-059)
draw_circle(world_pos, BLOB_RADIUS, blob_color)
else:
# Transitioning / recognized: outer glow + inner silhouette hint
var glow_color := blob_color
glow_color.a *= 0.4
draw_circle(world_pos, BLOB_RADIUS * SILHOUETTE_SCALE, glow_color)
draw_circle(world_pos, BLOB_RADIUS, blob_color)
# Faint silhouette: body shape rectangle emerges late in recognition
if color_t > SILHOUETTE_APPEAR_THRESHOLD:
var sil_color := blob_color
sil_color.a *= 0.6
var sil_size := SILHOUETTE_SIZE * color_t
draw_rect(Rect2(world_pos - sil_size * 0.5, sil_size), sil_color)
# Draw sound pings — concentric expanding rings
for ping in _pings:
var pos: Vector2 = ping.pos * TILE_SIZE
var t: float = ping.elapsed / PING_DURATION
var fade: float = 1.0 - t # Linear fade out
for ring_i in range(PING_RING_COUNT):
# Stagger rings: each starts slightly later
var ring_t: float = t - ring_i * 0.15
if ring_t < 0.0 or ring_t > 1.0:
continue
var radius: float = ring_t * PING_MAX_RADIUS
var ring_fade: float = (1.0 - ring_t) * fade
var ring_color := COLOR_PING
ring_color.a = ring_fade * 0.7
draw_arc(pos, radius, 0.0, TAU, 32, ring_color, PING_RING_WIDTH)
func _random_drift() -> Vector2:
return Vector2(
randf_range(-DRIFT_RANGE, DRIFT_RANGE),
randf_range(-DRIFT_RANGE, DRIFT_RANGE)
)
@@ -0,0 +1 @@
uid://cw154tgjdyhe1
+13 -3
View File
@@ -11,13 +11,18 @@ const TILE_SIZE := float(Constants.TILE_SIZE)
func _ready() -> void:
# Create the fog overlay ColorRect
# Create the fog overlay ColorRect — transparent fallback so a shader failure
# doesn't paint solid white over the entire world (z:900 covers everything).
_fog_rect = ColorRect.new()
_fog_rect.name = "FogRect"
_fog_rect.color = Color.TRANSPARENT
add_child(_fog_rect)
# Load shader and create material
var shader := load("res://shaders/fog.gdshader") as Shader
if shader == null:
push_error("FogShader: failed to load res://shaders/fog.gdshader — fog disabled")
return
_shader_mat = ShaderMaterial.new()
_shader_mat.shader = shader
_fog_rect.material = _shader_mat
@@ -38,14 +43,19 @@ func _ready() -> void:
func update_fog() -> void:
if _shader_mat == null:
return # Shader failed to load — fog disabled
# 1. Update FogState textures from GameState
FogState.update_from_state()
# 2. Position ColorRect to cover the current viewport
# 2. Position ColorRect to cover the current viewport.
# Use the camera's actual position (not player position) so the fog rect
# tracks the smoothed camera and never desynchronizes during smooth pan.
var vp_size := get_viewport().get_visible_rect().size
var cam := get_viewport().get_camera_2d()
var zoom := cam.zoom if cam else Vector2(2.0, 2.0)
var camera_pos := GameState.player_position * TILE_SIZE
var camera_pos := cam.global_position if cam else GameState.player_position * TILE_SIZE
var half_view := vp_size / (2.0 * zoom)
_fog_rect.position = camera_pos - half_view
_fog_rect.size = vp_size / zoom
@@ -0,0 +1 @@
uid://dje4vilxh5xxu
+30 -31
View File
@@ -38,37 +38,36 @@ void fragment() {
// Outside known map → unexplored
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
return;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (vis > PERIPHERAL_LOW) {
// In or near vision cone
if (vis > CLEAR_THRESHOLD) {
// Layer 1: Clear — soft edge gradient
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
} else {
// Layer 2: Light fog (peripheral + forward edge)
// D-059: animated Perlin noise, 8-10s cycle
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
COLOR = vec4(DARK_OVERLAY, alpha);
}
} else if (explored > 0.3) {
// Layer 3: Deep fog (previously explored, no longer in LOS)
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
COLOR = vec4(tint_color, alpha);
} else {
// Layer 5: Unexplored, no maps — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (vis > PERIPHERAL_LOW) {
// In or near vision cone
if (vis > CLEAR_THRESHOLD) {
// Layer 1: Clear — soft edge gradient
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
} else {
// Layer 2: Light fog (peripheral + forward edge)
// D-059: animated Perlin noise, 8-10s cycle
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
COLOR = vec4(DARK_OVERLAY, alpha);
}
} else if (explored > 0.3) {
// Layer 3: Deep fog (previously explored, no longer in LOS)
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
COLOR = vec4(tint_color, alpha);
} else {
// Layer 5: Unexplored, no maps — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://bcc1wwpk3moh1
Binary file not shown.
@@ -0,0 +1 @@

@@ -0,0 +1 @@
̀
@@ -0,0 +1 @@
����
Binary file not shown.
@@ -0,0 +1 @@
��
Binary file not shown.
@@ -0,0 +1 @@
��
Binary file not shown.
@@ -0,0 +1 @@
�����
Binary file not shown.
@@ -0,0 +1 @@
���
Binary file not shown.
@@ -0,0 +1 @@
��������
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.
+173
View File
@@ -0,0 +1,173 @@
## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing.
## Run with: make fixtures-client
## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd)
##
## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes.
## Together with the Rust-generated fixtures, this closes the cross-encoder
## compatibility loop (D-030 Layer 1, #475).
##
## Loads Messagepack encoder directly (not via class_name) because -s scripts
## run before the project's class_name registry is fully populated.
extends SceneTree
var _Msgpack: GDScript
var _count := 0
var _errors := 0
var _output_dir: String
func _init():
_run.call_deferred()
func _run():
_Msgpack = load("res://addons/messagepack/messagepack.gd")
# Resolve repo root from Godot project root (client/).
# Assumes client/ is one level below repo root — validated below.
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
_output_dir = repo_root.path_join("server/tests/fixtures/gdscript")
if not DirAccess.dir_exists_absolute(repo_root.path_join("server")):
push_error("Repo root detection failed: %s/server/ does not exist" % repo_root)
quit(1)
return
DirAccess.make_dir_recursive_absolute(_output_dir)
_generate_inputs()
_generate_boundary_inputs()
_generate_batch()
if _errors > 0:
push_error("FAILED: %d encode errors encountered" % _errors)
quit(1)
return
if _count == 0:
push_error("FAILED: no fixtures generated")
quit(1)
return
print("Generated %d GDScript fixtures at %s" % [_count, _output_dir])
quit()
func _write_fixture(name: String, bytes: PackedByteArray) -> void:
if bytes.is_empty():
push_error("Encode produced empty bytes for fixture: %s" % name)
_errors += 1
return
var path := _output_dir.path_join(name + ".msgpack")
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()])
_errors += 1
return
file.store_buffer(bytes)
file.flush()
file.close()
print(" Wrote %s (%d bytes)" % [name, bytes.size()])
_count += 1
## Encode a single PlayerInput to MessagePack bytes.
## Mirrors Protocol.encode_player_input() from protocol.gd.
func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action: Variant
if action_data != null:
action = {action_name: action_data}
elif action_name == "Interact":
action = {"Interact": {"target_entity_id": null, "verb": null}}
else:
action = action_name
var result = _Msgpack.encode({"tick": tick, "action": action})
if result.status != null:
push_error("Encode failed: %s" % result.status)
return PackedByteArray()
return result.value
## Encode an array of PlayerInputs to MessagePack bytes.
## Mirrors Protocol.encode_player_inputs() from protocol.gd.
func _encode_inputs(inputs: Array) -> PackedByteArray:
var wire_inputs: Array = []
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action: Variant
if action_data != null:
action = {action_name: action_data}
elif action_name == "Interact":
action = {"Interact": {"target_entity_id": null, "verb": null}}
else:
action = action_name
wire_inputs.append({"tick": input["tick"], "action": action})
var result = _Msgpack.encode(wire_inputs)
if result.status != null:
push_error("Batch encode failed: %s" % result.status)
return PackedByteArray()
return result.value
func _generate_inputs() -> void:
# Unit variants: all 8 movement directions (tick=100)
for dir_name in [
"MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
"MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest",
]:
_write_fixture("input_%s" % dir_name.to_snake_case(),
_encode_input(100, dir_name))
# Data variant: UsePerceptionMode (tick=200)
_write_fixture("input_perception_mode",
_encode_input(200, "UsePerceptionMode", "thermal"))
# Struct variant: Interact with null fields (tick=100)
_write_fixture("input_interact",
_encode_input(100, "Interact"))
# Other unit variants
_write_fixture("input_pause",
_encode_input(100, "Pause"))
_write_fixture("input_unpause",
_encode_input(100, "Unpause"))
_write_fixture("input_toggle_stance_up",
_encode_input(100, "ToggleStanceUp"))
_write_fixture("input_toggle_stance_down",
_encode_input(100, "ToggleStanceDown"))
_write_fixture("input_walk_away",
_encode_input(100, "WalkAway"))
func _generate_boundary_inputs() -> void:
# Tick values at MessagePack encoding format boundaries.
# GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd).
# GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce).
# Both are valid MessagePack. Rust's rmp_serde must accept both.
var boundary_ticks: Array = [
[0, "boundary_tick_0"],
[127, "boundary_tick_127"],
[128, "boundary_tick_128"],
[255, "boundary_tick_255"],
[256, "boundary_tick_256"], # int_16 asymmetry start
[32767, "boundary_tick_32767"], # int_16 asymmetry end
[32768, "boundary_tick_32768"],
[65535, "boundary_tick_65535"],
[65536, "boundary_tick_65536"], # int_32 asymmetry start
[2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end
]
for pair in boundary_ticks:
_write_fixture(pair[1],
_encode_input(pair[0], "Pause"))
func _generate_batch() -> void:
# Vec<PlayerInput> with two actions (mirrors Rust input_batch_two fixture)
_write_fixture("input_batch_two",
_encode_inputs([
{"tick": 0, "action_name": "MoveNorth"},
{"tick": 0, "action_name": "Interact"},
]))
+1
View File
@@ -0,0 +1 @@
uid://njychcgqlvtv
+612
View File
@@ -0,0 +1,612 @@
## Anti-tedium regression tests (#494):
## Guard against UI clutter that makes testing tedious.
##
## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands,
## the handler should pause, show capture dialog, save 3 files, unpause.
## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or
## gauntlet_mode flag in snapshot), timer and personal-bests must not appear.
##
## #495 blocked by #481/#490 (server). #496 blocked by #487 (server).
## Tests stub blocked features and verify anti-tedium guards.
## Spec ref: Sprint 9 briefing (client.md), #495, #496.
class_name TestAntiTedium
extends GdUnitTestSuite
var _instance: Node = null
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
var BugReportDialogScript = load("res://ui/bug_report_dialog.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.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests).
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
var snapshot := {
"tick": overrides.get("tick", 1),
"version": Protocol.PROTOCOL_VERSION,
"entities": overrides.get("entities", [{
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": "Player",
"visibility": "Forward",
}]),
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": overrides.get("tick_rate", "Full"),
},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"visible_tiles": [],
"nearby_interactions": [],
"pending_recognitions": [],
}
if overrides.has("current_monologue"):
snapshot["current_monologue"] = overrides["current_monologue"]
if overrides.has("current_dialogue"):
snapshot["current_dialogue"] = overrides["current_dialogue"]
# Gauntlet fields: only include if explicitly provided (absence = non-gauntlet)
if overrides.has("room_id"):
snapshot["room_id"] = overrides["room_id"]
if overrides.has("gauntlet_mode"):
snapshot["gauntlet_mode"] = overrides["gauntlet_mode"]
var result = Messagepack.encode(snapshot)
return result.value
func _make_gauntlet_hud() -> Control:
var hud = Control.new()
hud.set_script(GauntletHUDScript)
auto_free(hud)
add_child(hud)
hud._personal_bests = {} # Clear any stats loaded from disk
return hud
func _make_bug_report_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
auto_free(dialog)
add_child(dialog)
return dialog
# -- Test 1: F12 Bug Report Capture -------------------------------------------
# #495: BUG_REPORT action triggers the WRONG button capture flow.
# Verifies: dialog exists, activates on action, no state corruption.
# Note: Input.parse_input_event + _unhandled_input is unreliable in headless
# test mode. Tests drive the input queue directly for deterministic coverage.
func test_bug_report_dialog_exists_in_scene() -> void:
# Verify the BugReportDialog node is present and hidden by default.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
assert_that(dialog).override_failure_message(
"BugReportDialog node should exist in scene tree"
).is_not_null()
if dialog is CanvasItem:
assert_that((dialog as CanvasItem).visible).override_failure_message(
"BugReportDialog should be hidden by default"
).is_false()
func test_bug_report_activates_on_action() -> void:
# Inject BUG_REPORT action directly into the queue and verify main.gd
# triggers the dialog. This tests the full main._process() handling path.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
# Record baseline state
var mono_before: Variant = GameState.current_monologue
var dialogue_before: Variant = GameState.current_dialogue
# Inject BUG_REPORT action into InputMapper queue
InputMapper.input_queue.append({
"action": InputMapper.Action.BUG_REPORT,
"timestamp_msec": Time.get_ticks_msec(),
})
# Process a frame — main.gd flushes the queue and triggers dialog
_instance._process(0.016)
# Assert: dialog should be active
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
assert_that(dialog).is_not_null()
if dialog and dialog.has_method("is_active"):
assert_that(dialog.is_active()).override_failure_message(
"BugReportDialog should be active after BUG_REPORT action"
).is_true()
# Assert: no unintended state corruption
assert_that(GameState.current_monologue).override_failure_message(
"BUG_REPORT should not spawn a monologue"
).is_equal(mono_before)
assert_that(GameState.current_dialogue).override_failure_message(
"BUG_REPORT should not spawn a dialogue"
).is_equal(dialogue_before)
# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode -----------------
# When #496 lands, it adds a room timer and personal-bests overlay.
# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play.
#
# Current state: no gauntlet UI exists in the scene tree.
# This test guards against #496 accidentally showing gauntlet UI in all modes.
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
# Load main scene — represents non-Gauntlet (default) play mode
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Process one frame to initialize all children
_instance._process(0.016)
# Check that no gauntlet-specific UI nodes are visible in the scene tree.
# When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden
# by default (only shown when gauntlet_mode is true in the snapshot).
var gauntlet_node_names := [
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
"GauntletTimer", "GauntletProgress",
]
for node_name in gauntlet_node_names:
var node: Node = _find_node_recursive(_instance, node_name)
if node != null and node is CanvasItem:
assert_that((node as CanvasItem).visible).override_failure_message(
"Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name
).is_false()
func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
# A snapshot that lacks room_id and gauntlet_mode fields represents
# normal play. Apply it and verify no gauntlet state leaks into GameState.
var bytes := _make_snapshot_bytes({"tick": 1})
SimBridge.receive_bytes(bytes)
var snapshot: Variant = SimBridge._last_snapshot
assert_that(snapshot).is_not_null()
# Snapshot should NOT contain gauntlet fields
assert_that(snapshot.has("room_id")).override_failure_message(
"Non-gauntlet snapshot must not contain room_id"
).is_false()
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
"Non-gauntlet snapshot must not contain gauntlet_mode"
).is_false()
# Apply to GameState — gauntlet-related state should not exist
GameState.apply_snapshot(snapshot)
# #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false).
# A non-gauntlet snapshot must leave these at their defaults.
assert_that(GameState.room_id).override_failure_message(
"GameState.room_id should be null in non-gauntlet mode"
).is_null()
assert_that(GameState.gauntlet_mode).override_failure_message(
"GameState.gauntlet_mode should be false in non-gauntlet mode"
).is_false()
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
# Simulate several ticks of normal play — gauntlet UI must never appear.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Process 5 frames with normal (non-gauntlet) snapshots
for tick in range(1, 6):
var bytes := _make_snapshot_bytes({"tick": tick})
SimBridge.receive_bytes(bytes)
_instance._process(0.016)
# After 5 frames, no gauntlet nodes should have appeared
var gauntlet_node_names := [
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
"GauntletTimer", "GauntletProgress",
]
for node_name in gauntlet_node_names:
var node: Node = _find_node_recursive(_instance, node_name)
if node != null and node is CanvasItem:
assert_that((node as CanvasItem).visible).override_failure_message(
"Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5]
).is_false()
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
# Timer lifecycle, personal bests, room change, visibility.
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
func test_format_time_zero() -> void:
# Static utility: 0 seconds → "00:00"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(0.0)).is_equal("00:00")
func test_format_time_sub_minute() -> void:
# 47.9 seconds (truncates, no rounding) → "00:47"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(47.9)).is_equal("00:47")
func test_format_time_over_minute() -> void:
# 98.3 seconds → 1 min 38 sec → "01:38"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(98.3)).is_equal("01:38")
func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void:
# HUD must become visible when gauntlet_mode is true in the snapshot.
var hud := _make_gauntlet_hud()
assert_that(hud.visible).is_false()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"GauntletHUD must be visible when gauntlet_mode is true"
).is_true()
func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void:
# HUD must hide when gauntlet_mode transitions from true to false.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.visible).is_true()
# Switch to non-gauntlet
GameState.gauntlet_mode = false
GameState.room_id = null
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"GauntletHUD must hide when gauntlet_mode becomes false"
).is_false()
func test_gauntlet_hud_timer_starts_on_room_entry() -> void:
# Timer starts running and tracks room_id after room entry.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should be running after room entry"
).is_true()
assert_that(hud.get_current_room_id()).is_equal("room_1")
func test_gauntlet_hud_timer_increments_with_delta() -> void:
# Timer accumulates real time via _process(delta).
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(0.5)
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should increment by delta (0.5s)"
).is_equal_approx(0.5, 0.01)
hud._process(1.0)
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should accumulate (0.5 + 1.0 = 1.5s)"
).is_equal_approx(1.5, 0.01)
func test_gauntlet_hud_timer_resets_on_room_change() -> void:
# Changing room_id resets the timer to 0 and starts counting for the new room.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(5.0)
assert_that(hud.get_timer_seconds() > 4.0).is_true()
# Change room
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should reset to 0 on room change"
).is_equal_approx(0.0, 0.01)
assert_that(hud.get_current_room_id()).is_equal("room_2")
assert_that(hud.is_timer_running()).is_true()
func test_gauntlet_hud_records_personal_best() -> void:
# Completing a room (by entering a new room) records a personal best.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(10.0)
# Entering room_2 triggers _record_room_completion for room_1
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB for room_1 should be ~10.0s after first completion"
).is_equal_approx(10.0, 0.1)
func test_gauntlet_hud_non_pb_does_not_overwrite() -> void:
# A worse time must not overwrite an existing personal best.
var hud := _make_gauntlet_hud()
hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(10.0) # Worse time than existing PB
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB should remain 5.0 when new time (10.0) is worse"
).is_equal_approx(5.0, 0.01)
func test_gauntlet_hud_pb_overwrites_when_faster() -> void:
# A faster time must overwrite an existing personal best.
var hud := _make_gauntlet_hud()
hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(8.0) # Better time
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB should update to 8.0 when new time beats old PB (15.0)"
).is_equal_approx(8.0, 0.1)
func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void:
# Gauntlet mode active but no room_id yet — HUD visible, timer stopped.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = null
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"HUD should be visible in gauntlet mode even without room_id"
).is_true()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should NOT run when room_id is null"
).is_false()
func test_gauntlet_hud_timer_paused_when_not_visible() -> void:
# Timer should not accumulate when HUD is not visible (non-gauntlet mode).
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(2.0)
assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01)
# Switch to non-gauntlet (hides HUD)
GameState.gauntlet_mode = false
hud.update_from_state()
hud._process(5.0) # Should NOT accumulate
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer must not accumulate when HUD is hidden"
).is_equal_approx(2.0, 0.01)
func test_gauntlet_hud_finalize_records_current_room() -> void:
# finalize() records the current room's time and stops the timer.
# Called on disconnect via _on_connection_state_changed.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(7.5)
hud.finalize()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should stop after finalize"
).is_false()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"Finalize should record current room's time as PB"
).is_equal_approx(7.5, 0.1)
func test_gauntlet_hud_session_tracks_attempts() -> void:
# Each room entry increments the attempt counter in _session_rooms.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(3.0)
# Re-enter same room (via room change and back)
GameState.room_id = "room_2"
hud.update_from_state()
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud._session_rooms.has("room_1")).is_true()
assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message(
"Room should show 2 attempts after two entries"
).is_equal(2)
func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
# Snapshot with gauntlet fields applied to GameState, consumed by HUD.
# End-to-end: bytes → decode → apply → update_from_state.
var bytes := _make_snapshot_bytes({
"tick": 10,
"gauntlet_mode": true,
"room_id": "warehouse_01",
})
SimBridge.receive_bytes(bytes)
GameState.apply_snapshot(SimBridge._last_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("warehouse_01")
var hud := _make_gauntlet_hud()
hud.update_from_state()
assert_that(hud.visible).is_true()
assert_that(hud.get_current_room_id()).is_equal("warehouse_01")
assert_that(hud.is_timer_running()).is_true()
# -- BugReportDialog Feature Tests (#495) ------------------------------------
# Pause/unpause lifecycle, wire guard, text render, state machine.
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
func test_bug_report_sends_pause_on_open() -> void:
# start_capture() must send Pause to the server.
SimBridge.connect_to_sim()
SimBridge._test_input_queue.clear()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
assert_that(dialog.is_active()).is_true()
assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message(
"Opening bug report should send Pause to server"
).is_true()
func test_bug_report_sends_unpause_on_close() -> void:
# _close() must send Unpause to the server.
SimBridge.connect_to_sim()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
SimBridge._test_input_queue.clear()
dialog._close()
assert_that(dialog.is_active()).is_false()
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
"Closing bug report should send Unpause to server"
).is_true()
func test_bug_report_not_double_activatable() -> void:
# Calling start_capture() twice must not double-activate or send duplicate Pause.
SimBridge.connect_to_sim()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
assert_that(dialog.is_active()).is_true()
SimBridge._test_input_queue.clear()
dialog.start_capture() # Second call — should be blocked
assert_that(SimBridge._test_input_queue.size()).override_failure_message(
"Double-activation should be blocked (no second Pause sent)"
).is_equal(0)
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)
assert_that(wire_name).override_failure_message(
"BUG_REPORT must not produce a wire action name (client-only)"
).is_equal("")
func test_bug_report_render_text_contains_tick_and_entities() -> void:
# _render_snapshot_text() must include tick number and entity data from GameState.
GameState.current_tick = 42
GameState.player_position = Vector2(5.0, 7.0)
GameState.player_facing = "South"
GameState.player_stance = "Crouch"
GameState.visible_entities = [{
"entity_id": 1, "x": 5.0, "y": 7.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward",
}]
GameState.visible_tiles = []
GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
assert_that(text.contains("t42")).override_failure_message(
"Render text should contain tick number"
).is_true()
assert_that(text.contains("Entities (1)")).override_failure_message(
"Render text should show entity count"
).is_true()
assert_that(text.contains("Player")).override_failure_message(
"Render text should contain entity kind"
).is_true()
assert_that(text.contains("South")).override_failure_message(
"Render text should contain player facing"
).is_true()
func test_bug_report_render_text_with_monologue_and_dialogue() -> void:
# _render_snapshot_text() should include active monologue and dialogue.
GameState.current_tick = 10
GameState.player_position = Vector2(3.0, 3.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"}
GameState.current_monologue = {"text": "Something is wrong here."}
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"}
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
assert_that(text.contains("Something is wrong here.")).override_failure_message(
"Render text should include monologue text"
).is_true()
assert_that(text.contains("Kael")).override_failure_message(
"Render text should include dialogue NPC name"
).is_true()
assert_that(text.contains("Who are you?")).override_failure_message(
"Render text should include dialogue speech"
).is_true()
func test_bug_report_render_text_empty_snapshot() -> void:
# Edge case: F12 pressed before any snapshot data arrives.
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
# Should not crash, should produce some output
assert_that(text.length() > 0).override_failure_message(
"Render text should not be empty even with no snapshot data"
).is_true()
assert_that(text.contains("Entities (0)")).override_failure_message(
"Empty snapshot should show 0 entities"
).is_true()
# -- 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
+1
View File
@@ -0,0 +1 @@
uid://d3fj584itowcl
+145
View File
@@ -0,0 +1,145 @@
## Tests for camera anchor behavior — the camera must show the player's
## position from the very first rendered frame, with no visible drift from (0,0).
##
## These tests verify the full pipeline: SimBridge → GameState → Camera2D.
## The player starts at (10,10) in test mode, so the expected camera position
## is (10,10) * TILE_SIZE = (320, 320).
class_name TestCameraAnchor
extends GdUnitTestSuite
const EXPECTED_PLAYER_POS := Vector2(10, 10)
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
var _instance: Node = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# --- Pipeline precondition tests ---
func test_simbridge_connected_in_test_mode() -> void:
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
SimBridge.connect_to_sim()
assert_that(SimBridge.state).is_equal(SimBridge.ConnectionState.CONNECTED)
func test_poll_snapshot_returns_data_when_connected() -> void:
SimBridge.connect_to_sim()
var snapshot: Variant = SimBridge.poll_snapshot()
assert_that(snapshot).is_not_null()
assert_that(snapshot is Dictionary).is_true()
func test_snapshot_has_player_at_10_10() -> void:
var snap := SimBridge._test_snapshot()
var player_x: float = -1.0
var player_y: float = -1.0
for entity in snap.entities:
if entity.kind.variant == "Player":
player_x = entity.x
player_y = entity.y
break
assert_that(player_x).is_equal(10.0)
assert_that(player_y).is_equal(10.0)
func test_apply_snapshot_sets_player_position() -> void:
var snap := SimBridge._test_snapshot()
GameState.apply_snapshot(snap)
assert_that(GameState.player_position).is_equal(EXPECTED_PLAYER_POS)
# --- Camera anchor after _ready() ---
func test_camera_position_after_ready() -> void:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.global_position).is_equal(EXPECTED_CAMERA_POS)
func test_camera_anchored_flag_after_ready() -> void:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
assert_that(_instance._camera_anchored).is_true()
func test_camera_smoothing_off_after_ready() -> void:
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
# If this test fails, the camera will visibly drift from origin to player.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_false()
# --- Camera behavior across frames ---
func test_camera_tracks_player_after_process() -> void:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Simulate one game frame
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).is_equal(expected)
func test_camera_smoothing_reenabled_after_process() -> void:
# After the first anchored frame, smoothing should be back on for gameplay.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
var camera: Camera2D = _instance.get_node("Camera2D")
assert_that(camera.position_smoothing_enabled).is_true()
func test_camera_follows_player_movement() -> void:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var initial_pos := camera.global_position
# Move player north via SimBridge test mode
SimBridge._test_input_queue.append("MoveNorth")
_instance._process(0.016)
# Camera should have moved with the player
assert_that(camera.global_position.y < initial_pos.y).is_true()
assert_that(camera.global_position).is_equal(
GameState.player_position * Constants.TILE_SIZE)
+1
View File
@@ -0,0 +1 @@
uid://vw0iqhai1kum
+206
View File
@@ -0,0 +1,206 @@
## P1 client tests: fog shader state (4), entity lifecycle (2), pending recognition blob (1).
## Information boundary + core rendering contract tests — the perception/fog system
## that makes asymmetric information work.
## Bug #5 class prevention: fog byte encoding errors. Bug #2 class: entity lifecycle.
## Spec ref: stig-round3.md Section 1 (tests #3-#9), workshop-outcomes.md.
class_name TestClientP1
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
# -- Helpers -------------------------------------------------------------------
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestClientP1: FogState autoload not found — test skipped")
return node
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Fog state: visibility byte values (#3-#4) --------------------------------
func test_fog_visibility_forward_tile() -> void:
# P1 #3: Forward-sector tile writes VIS_FORWARD (255) to _vis_bytes.
var fog = _get_fog_state()
if fog == null:
return
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
fog.update_from_state()
# Index: row 5 * width 64 + col 5
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
).is_equal(FogState.VIS_FORWARD)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_fog_visibility_peripheral_tile() -> void:
# P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes.
var fog = _get_fog_state()
if fog == null:
return
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
GameState.visible_positions = {Vector2i(10, 8): true}
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
fog.update_from_state()
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
).is_equal(FogState.VIS_PERIPHERAL)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog state: exploration persistence (#5) -----------------------------------
func test_fog_exploration_persistence() -> void:
# P1 #5: Tile visible in frame 1 (EXP_VISIBLE), not visible in frame 2
# → _exp_bytes decays to EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
var fog = _get_fog_state()
if fog == null:
return
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
var pos := Vector2i(5, 5)
var idx: int = 5 * 64 + 5
# Frame 1: tile visible
GameState.visible_positions = {pos: true}
GameState.visibility_sectors = {pos: "Forward"}
fog.update_from_state()
assert_that(fog._exp_bytes[idx]).override_failure_message(
"Visible tile should have EXP_VISIBLE=%d" % FogState.EXP_VISIBLE
).is_equal(FogState.EXP_VISIBLE)
# Frame 2: tile no longer visible — should decay to EXP_EXPLORED, not EXP_UNEXPLORED
GameState.visible_positions = {}
GameState.visibility_sectors = {}
fog.update_from_state()
assert_that(fog._exp_bytes[idx]).override_failure_message(
"Previously visible tile should decay to EXP_EXPLORED=%d, not EXP_UNEXPLORED" % FogState.EXP_EXPLORED
).is_equal(FogState.EXP_EXPLORED)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog state: hidden tile default (#6) --------------------------------------
func test_fog_hidden_tile_value() -> void:
# P1 #6: Tile never in visible_positions → _vis_bytes == VIS_HIDDEN (0).
# Verifies default state after _resize() and after update with other visible tiles.
var fog = _get_fog_state()
if fog == null:
return
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
# Check a tile that was never made visible (30,30)
var idx: int = 30 * 64 + 30
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
).is_equal(FogState.VIS_HIDDEN)
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
fog.update_from_state()
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
).is_equal(FogState.VIS_HIDDEN)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Entity lifecycle: removal on leaving visibility (#7) ----------------------
func test_entity_removed_when_leaving_visibility() -> void:
# P1 #7: Entity present in one update, absent in next → entity_nodes empty.
# Verifies removal (queue_free), not just hiding.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 42, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Entity should exist after first update"
).is_equal(1)
assert_that(renderer.entity_nodes.has(42)).is_true()
# Entity leaves visibility — removed from entity_nodes, node queue_free'd
renderer.update_entities([])
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Entity should be removed when absent from update"
).is_equal(0)
assert_that(renderer.entity_nodes.has(42)).is_false()
renderer.queue_free()
# -- Entity lifecycle: creation on first appearance (#8) -----------------------
func test_entity_created_on_first_appearance() -> void:
# P1 #8: Empty renderer → update with 1 entity → entity_nodes.size() == 1,
# child exists in scene tree.
var renderer := _make_entity_renderer()
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Fresh renderer should have no entities"
).is_equal(0)
var entity := [{"entity_id": 99, "x": 3.0, "y": 7.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Renderer should have 1 entity after update"
).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
# Verify child actually exists in the scene tree
var node = renderer.entity_nodes[99]
assert_that(node.get_parent()).is_equal(renderer)
renderer.queue_free()
# -- Pending recognition blob rendering (#9) ----------------------------------
func test_pending_recognition_blob_rendering() -> void:
# P1 #9: pending_recognitions with 1 entry → FogEntities tracks blob entity
# (not a full entity sprite — drawn via _draw(), data in _entities dict).
var fog_entities := _make_fog_entities()
var saved_recognitions := GameState.pending_recognitions
GameState.pending_recognitions = [{
"entity_id": 77,
"x": 10.0,
"y": 15.0,
"z": 0,
"remaining_ticks": 5,
"total_delay_ticks": 10,
}]
fog_entities.update_from_state()
# FogEntities uses _draw() with no child nodes — verify internal tracking
assert_that(fog_entities._entities.size()).override_failure_message(
"FogEntities should track 1 blob entity"
).is_equal(1)
assert_that(fog_entities._entities.has(77)).is_true()
# Verify position stored correctly
var blob_data: Dictionary = fog_entities._entities[77]
assert_that(blob_data.pos).is_equal(Vector2(10.0, 15.0))
# Recognition progress should be calculated: 1.0 - 5/10 = 0.5
assert_that(blob_data.progress).is_equal_approx(0.5, 0.01)
# New entity triggers a sonar ping (D-059)
assert_that(fog_entities._pings.size()).override_failure_message(
"New fog entity should trigger a sonar ping"
).is_greater_equal(1)
# Cleanup
GameState.pending_recognitions = saved_recognitions
fog_entities.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://cw0yyp8mrchi7
+320
View File
@@ -0,0 +1,320 @@
## P2 client tests: camera (5), entity alpha/color (4), UI (7).
## Validates camera tracking, entity visual state, and UI component lifecycle.
## Spec ref: sprint-9/client.md #492.
class_name TestClientP2
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
var _instance: Node = null
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.game_time = {}
GameState.pending_recognitions = []
GameState.nearby_interactions = []
GameState.player_inventory = []
GameState.player_stance = "Walk"
GameState.dialogue_active = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
func _make_scene() -> Node:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
return _instance
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Camera (5) ----------------------------------------------------------------
func test_camera_zoom_default_2x() -> void:
# P2-C01: Camera zoom should be 2x as set in main.tscn.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
assert_that(camera.zoom).is_equal(Vector2(2, 2))
func test_camera_smoothing_convergence() -> void:
# P2-C02: After first _process, smoothing re-enables for gameplay feel.
# After several frames, camera position should still match player position
# (smoothing converges because target == position when stationary).
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
# First frame re-enables smoothing
inst._process(0.016)
assert_that(camera.position_smoothing_enabled).is_true()
# Several more frames — stationary player, camera converges
for i in 5:
inst._process(0.016)
var expected := GameState.player_position * Constants.TILE_SIZE
var dist := camera.global_position.distance_to(expected)
assert_that(dist < 0.1).override_failure_message(
"Camera should converge to player position (dist: %.4f)" % dist
).is_true()
func test_camera_viewport_tracks_player_position() -> void:
# P2-C03: Camera position always equals player_position * TILE_SIZE.
# No offset, no viewport clamping beyond player tracking.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).override_failure_message(
"Camera must track player position exactly"
).is_equal(expected)
func test_camera_follows_player_after_movement() -> void:
# P2-C04: After player moves, camera position updates to new player position.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
var initial_pos := camera.global_position
# Move player south via SimBridge test mode
SimBridge._test_input_queue.append("MoveSouth")
inst._process(0.016)
# Camera should have moved with player
assert_that(camera.global_position.y > initial_pos.y).override_failure_message(
"Camera Y should increase after moving south"
).is_true()
assert_that(camera.global_position).is_equal(
GameState.player_position * Constants.TILE_SIZE)
func test_camera_no_panning_locked_to_player() -> void:
# P2-C05 (D-014): Camera is locked to player — no panning.
# Camera position equals player_position * TILE_SIZE every frame.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
# Process multiple frames
for i in 10:
inst._process(0.016)
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).override_failure_message(
"Frame %d: camera must be locked to player" % i
).is_equal(expected)
# -- Entity alpha/color (4) ---------------------------------------------------
func test_entity_null_visibility_defaults_full_alpha() -> void:
# P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[50]
assert_that(node.modulate.a).override_failure_message(
"Null visibility should default to full alpha"
).is_equal_approx(1.0, 0.01)
renderer.queue_free()
func test_entity_peripheral_to_forward_alpha_transition() -> void:
# P2-E02: Updating entity from Peripheral to Forward changes alpha.
var renderer := _make_entity_renderer()
# First: Peripheral
var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
"kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}]
renderer.update_entities(entity_p)
var node = renderer.entity_nodes[60]
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
# Update to Forward
var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
"kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity_f)
assert_that(node.modulate.a).override_failure_message(
"Forward visibility should set alpha to 1.0"
).is_equal_approx(1.0, 0.01)
renderer.queue_free()
func test_entity_terrain_uses_object_color() -> void:
# P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[70] as ColorRect
assert_that(node.color).override_failure_message(
"Terrain kind should use ENTITY_COLOR_OBJECT"
).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
func test_entity_player_color_regardless_of_sector() -> void:
# P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector.
GameState.player_entity_id = 80
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[80] as ColorRect
assert_that(node.color).override_failure_message(
"Player color must be constant regardless of visibility sector"
).is_equal(Constants.ENTITY_COLOR_PLAYER)
# Alpha should still be dimmed for Peripheral
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
renderer.queue_free()
# -- UI (7) --------------------------------------------------------------------
func test_monologue_display_visible_hidden() -> void:
# P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue.
# Note: mono.is_visible is a custom bool property on MonologueDisplay
# (monologue_display.gd:11), not the built-in CanvasItem.is_visible() method.
# The monologue uses tween alpha for visual hide/show, so the built-in
# .visible stays true — we test the script's own state tracking.
var inst := _make_scene()
var mono = inst.get_node("UILayer/MonologueDisplay")
assert_that(mono.is_visible).override_failure_message(
"Monologue should start hidden"
).is_false()
mono.show_monologue("Test thought.", 3.0)
assert_that(mono.is_visible).override_failure_message(
"Monologue should be visible after show_monologue"
).is_true()
assert_that(mono.text_label.text).is_equal("Test thought.")
func test_interaction_list_shows_nearest_verb() -> void:
# P2-U02: When nearby_interactions are present, interaction list shows verbs.
var inst := _make_scene()
var ilist = inst.get_node("InsertOverlay/InteractionList")
GameState.nearby_interactions = [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "Observe", "label": "Observe", "priority": 2, "available": true},
],
}]
ilist.update_from_state()
assert_that(ilist.get_visible_verb_count()).override_failure_message(
"Interaction list should show 2 verbs"
).is_equal(2)
assert_that(ilist.is_showing()).is_true()
# Cleanup
GameState.nearby_interactions = []
func test_inventory_grid_shows_carried_items() -> void:
# P2-U03: Inventory grid becomes visible when player has items.
var inst := _make_scene()
var inv = inst.get_node("UILayer/InventoryGrid")
# Initially empty — hidden
GameState.player_inventory = []
inv.update_from_state()
assert_that(inv.visible).override_failure_message(
"Inventory should be hidden when empty"
).is_false()
# Add items
GameState.player_inventory = [
{"item_id": 1, "name": "Keycard", "slot": 0},
{"item_id": 2, "name": "Datapad", "slot": 1},
]
inv.update_from_state()
assert_that(inv.visible).override_failure_message(
"Inventory should be visible with items"
).is_true()
assert_that(inv.get_slot_count()).is_equal(2)
# Cleanup
GameState.player_inventory = []
func test_dialogue_overlay_active_on_show() -> void:
# P2-U04: Dialogue box reports active after show_dialogue.
var inst := _make_scene()
var dlg = inst.get_node("InsertOverlay/DialogueBox")
assert_that(dlg.is_dialogue_active()).override_failure_message(
"Dialogue should start inactive"
).is_false()
dlg.show_dialogue("Kael", "How's it going?", [
{"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false},
])
assert_that(dlg.is_dialogue_active()).override_failure_message(
"Dialogue should be active after show_dialogue"
).is_true()
func test_game_state_tick_rate_paused() -> void:
# P2-U05: TickRate::Paused is stored in game_time and queryable.
GameState.apply_snapshot({
"tick": 1,
"game_time": {
"day": 0,
"time_of_day": 100,
"day_phase": "Morning",
"tick_rate": "Paused",
},
})
assert_that(GameState.game_time.tick_rate).override_failure_message(
"Paused tick rate must be stored in game_time"
).is_equal("Paused")
func test_fog_entities_blob_count_matches_recognitions() -> void:
# P2-U06: FogEntities tracks one blob per pending recognition.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
GameState.pending_recognitions = [
{"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6},
{"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6},
]
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).override_failure_message(
"FogEntities should track 2 blob entities"
).is_equal(2)
assert_that(fog_entities._entities.has(101)).is_true()
assert_that(fog_entities._entities.has(102)).is_true()
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_fog_overlay_z_index_covers_world() -> void:
# P2-U07: FogOverlay renders above world content (z:900), ensuring
# occluded tiles are visually covered.
var inst := _make_scene()
var fog_overlay = inst.get_node("World/FogOverlay")
assert_that(fog_overlay.z_index).override_failure_message(
"FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG
).is_equal(Constants.Z_FOG)
# FogEntities at z:950 — above fog, below insert overlay
var fog_entities = inst.get_node("World/FogEntities")
assert_that(fog_entities.z_index).override_failure_message(
"FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES
).is_equal(Constants.Z_FOG_ENTITIES)
+1
View File
@@ -0,0 +1 @@
uid://ci76ggq7n33w7
+339
View File
@@ -0,0 +1,339 @@
## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5).
## Validates scene tree draw order, framerate-independent entity interpolation,
## and recognition transition timing.
## Spec ref: sprint-9/client.md #493.
class_name TestClientP3
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
var _instance: Node = null
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.game_time = {}
GameState.pending_recognitions = []
GameState.nearby_interactions = []
GameState.player_inventory = []
GameState.player_stance = "Walk"
GameState.player_facing = "North"
GameState.dialogue_active = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
func _make_scene() -> Node:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
return _instance
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Z-layer ordering (4) -----------------------------------------------------
func test_z_floor_below_ysort() -> void:
# P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100).
var inst := _make_scene()
var floor_tiles = inst.get_node("World/FogGroup/FloorTiles")
var ysort = inst.get_node("World/FogGroup/YSortGroup")
assert_that(floor_tiles.z_index).override_failure_message(
"FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR
).is_equal(Constants.Z_FLOOR)
assert_that(ysort.z_index).override_failure_message(
"YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT
).is_equal(Constants.Z_YSORT)
assert_that(floor_tiles.z_index < ysort.z_index).is_true()
func test_z_entities_inside_ysort_at_zero() -> void:
# P3-Z02: Entities node lives inside YSortGroup with z_index = 0.
# Y-sort contract: children of YSortGroup must use z_index = 0.
var inst := _make_scene()
var entities = inst.get_node("World/FogGroup/YSortGroup/Entities")
assert_that(entities.z_index).override_failure_message(
"Entities z_index must be 0 inside YSortGroup (y-sort contract)"
).is_equal(0)
assert_that(entities.get_parent().y_sort_enabled).override_failure_message(
"Entities parent must have y_sort_enabled"
).is_true()
func test_z_fog_above_world_content() -> void:
# P3-Z03: FogOverlay (z:900) renders above all world content including
# YSortGroup (z:100) and Overhead (z:300).
# Spec #34: FogOverlay z_index == Z_FOG (900).
# Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950).
var inst := _make_scene()
var fog = inst.get_node("World/FogOverlay")
var fog_entities = inst.get_node("World/FogEntities")
var fog_group = inst.get_node("World/FogGroup")
var overhead = inst.get_node("World/FogGroup/Overhead")
# FogOverlay must be a sibling of FogGroup (both children of World),
# not a child of FogGroup — fog renders OVER the composited group.
assert_that(fog.get_parent()).override_failure_message(
"FogOverlay must be sibling of FogGroup (both under World)"
).is_equal(fog_group.get_parent())
assert_that(fog.z_index).override_failure_message(
"FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG
).is_equal(Constants.Z_FOG)
assert_that(fog_entities.z_index).override_failure_message(
"FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES
).is_equal(Constants.Z_FOG_ENTITIES)
assert_that(fog.z_index > overhead.z_index).override_failure_message(
"FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index]
).is_true()
assert_that(fog_entities.z_index > fog.z_index).override_failure_message(
"FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index]
).is_true()
func test_z_ui_layer_above_world() -> void:
# P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10)
# and both render above world content.
var inst := _make_scene()
var ui_layer = inst.get_node("UILayer") as CanvasLayer
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
var modal_layer = inst.get_node("ModalLayer") as CanvasLayer
assert_that(insert_layer.layer).override_failure_message(
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
).is_equal(Constants.CANVAS_INSERT)
assert_that(ui_layer.layer).override_failure_message(
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
).is_equal(Constants.CANVAS_UI)
assert_that(modal_layer.layer).override_failure_message(
"ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
).is_equal(Constants.CANVAS_MODAL)
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
"UILayer must render above InsertOverlay"
).is_true()
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
"ModalLayer must render above UILayer"
).is_true()
# -- Entity lerp (3) ----------------------------------------------------------
func test_entity_snap_on_first_appear() -> void:
# P3-L01: Entity spawns at its position immediately — no lerp on first appear.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected := Vector2(
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
assert_that(node.position).override_failure_message(
"Entity should snap to position on first appear (no lerp)"
).is_equal(expected)
renderer.queue_free()
func test_entity_lerp_moves_toward_target() -> void:
# P3-L02: After updating target position, entity moves toward it over time.
var renderer := _make_entity_renderer()
# Spawn at (5, 5)
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node: ColorRect = renderer.entity_nodes[11]
var start_pos: Vector2 = node.position
# Move target to (6, 5)
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
# Process several frames — entity should move toward target
renderer._process(0.016)
renderer._process(0.016)
var after_pos: Vector2 = node.position
var target := Vector2(
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Position should have moved toward target (x increased)
assert_that(after_pos.x > start_pos.x).override_failure_message(
"Entity x should move toward target after _process"
).is_true()
# But should not have snapped — still in transit
assert_that(after_pos.x < target.x).override_failure_message(
"Entity should still be in transit after 2 frames"
).is_true()
renderer.queue_free()
func test_entity_lerp_converges_within_300ms() -> void:
# P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s.
# At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there.
var renderer := _make_entity_renderer()
# Spawn at (5, 5)
var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
# Move target to (7, 5) — 2 tiles
var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
var target := Vector2(
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
for i in 20:
renderer._process(0.016)
var final_node: ColorRect = renderer.entity_nodes[12]
var final_pos: Vector2 = final_node.position
# Should be within 5% of target (97% convergence at 0.3s)
var dist: float = final_pos.distance_to(target)
var total_dist: float = 2.0 * Constants.TILE_SIZE
assert_that(dist / total_dist < 0.05).override_failure_message(
"Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist]
).is_true()
renderer.queue_free()
# -- Tyre additions (5) -------------------------------------------------------
func test_recognition_transition_progress() -> void:
# P3-T01: Recognition progress calculated correctly from remaining/total ticks.
# Grey blob → colored entity over total_delay_ticks.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
# 3 remaining out of 10 total → progress 0.7
GameState.pending_recognitions = [{
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 3, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
var blob: Dictionary = fog_entities._entities[200]
assert_that(blob.progress).override_failure_message(
"Progress should be 0.7 (1.0 - 3/10)"
).is_equal_approx(0.7, 0.01)
# 0 remaining → fully recognized (progress 1.0)
GameState.pending_recognitions = [{
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 0, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
assert_that(fog_entities._entities[200].progress).override_failure_message(
"Zero remaining should be progress 1.0"
).is_equal_approx(1.0, 0.01)
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_player_facing() -> void:
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
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,
}
for dir in expected:
GameState.player_facing = dir
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)
renderer.queue_free()
func test_lerp_weight_increases_with_delta() -> void:
# P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival.
# Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta).
# Higher delta (or higher lerp multiplier) means more progress per frame.
var renderer := _make_entity_renderer()
# Spawn entity, then move target
var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
# Small delta step
var small_node: ColorRect = renderer.entity_nodes[20]
var small_start: float = small_node.position.x
renderer._process(0.008)
var small_progress: float = small_node.position.x - small_start
# Reset position for large delta test
small_node.position = Vector2(
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Large delta step
var large_start: float = small_node.position.x
renderer._process(0.032)
var large_progress: float = small_node.position.x - large_start
assert_that(large_progress > small_progress).override_failure_message(
"Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress]
).is_true()
renderer.queue_free()
func test_recognition_blob_removed_when_absent() -> void:
# P3-T04: When a pending recognition disappears from state, FogEntities
# removes the blob.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
# Add entity
GameState.pending_recognitions = [{
"entity_id": 300, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 5, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).is_equal(1)
# Remove from state
GameState.pending_recognitions = []
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).override_failure_message(
"Blob should be removed when absent from pending_recognitions"
).is_equal(0)
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_entity_renderer_lerp_speed_constant() -> void:
# P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant
# for entity visual interpolation. Pin value to prevent accidental changes.
assert_that(EntityRenderer.LERP_SPEED).override_failure_message(
"LERP_SPEED must be 12.0 (entity movement feel constant)"
).is_equal(12.0)
+1
View File
@@ -0,0 +1 @@
uid://bas5l8rnvsjly
+1
View File
@@ -0,0 +1 @@
uid://mg1wnv1ydc6m
+1
View File
@@ -0,0 +1 @@
uid://foiy3bk1jko7
@@ -0,0 +1 @@
uid://w7k3k1584ir7
+7 -3
View File
@@ -195,15 +195,19 @@ func test_prompt_hidden_initially() -> void:
# -- Input encoding: Interact --
func test_interact_encodes_as_unit_variant() -> void:
func test_interact_encodes_as_struct_variant() -> void:
# Interact is always a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
var inputs: Array = [{"tick": 100, "action_name": "Interact"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("Interact")
assert_that(raw.value[0]["action"] is Dictionary).is_true()
assert_that(raw.value[0]["action"].has("Interact")).is_true()
var interact_data: Dictionary = raw.value[0]["action"]["Interact"]
assert_that(interact_data.has("target_entity_id")).is_true()
assert_that(interact_data.has("verb")).is_true()
func test_interact_with_data_encodes_as_data_variant() -> void:
# Future: once server accepts Interact(InteractData)
var inputs: Array = [{
"tick": 100,
"action_name": "Interact",
+3 -1
View File
@@ -142,7 +142,9 @@ func test_framed_protocol_batch_input_roundtrip() -> void:
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
# -- Diagonal movement wire mapping --------------------------------------------
+222
View File
@@ -0,0 +1,222 @@
## Boundary value tests for MessagePack encoding: 25 positive + 16 negative boundaries.
## Validates header byte + payload for each value, encode-decode roundtrip,
## and cross-encoding decode (GDScript accepting Rust-style unsigned encodings).
## Bug #4 class prevention: format boundary encoding errors.
## Spec ref: hoshe-round3.md Section 4, workshop-outcomes.md Appendix C.
class_name TestMsgpackBoundaries
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
## Assert that encoding `value` produces exactly `expected_bytes`.
func _assert_encodes_to(value: int, expected_bytes: PackedByteArray, label: String) -> void:
var result = Messagepack.encode(value)
assert_that(result.value).override_failure_message(
"%s: encode(%d) produced wrong bytes" % [label, value]
).is_equal(expected_bytes)
## Assert that encoding then decoding `value` returns the original value.
func _assert_roundtrip(value: int, label: String) -> void:
var encoded = Messagepack.encode(value)
var decoded = Messagepack.decode(encoded.value)
assert_that(decoded.value).override_failure_message(
"%s: roundtrip failed for %d" % [label, value]
).is_equal(value)
# -- Positive boundaries: encode-only -----------------------------------------
func test_encode_pos_fixint() -> void:
# BV-P01 to BV-P04: positive fixint range (0 to 127)
# Format: single byte = value itself
_assert_encodes_to(0, PackedByteArray([0x00]), "BV-P01")
_assert_encodes_to(1, PackedByteArray([0x01]), "BV-P02")
_assert_encodes_to(126, PackedByteArray([0x7e]), "BV-P03")
_assert_encodes_to(127, PackedByteArray([0x7f]), "BV-P04")
func test_encode_uint8() -> void:
# BV-P05 to BV-P08: uint 8 range (128 to 255)
# Format: 0xcc + 1 byte
# Bug #4 regression target: 128 must NOT encode as int_8 (0xd0, 0x80 = -128)
_assert_encodes_to(128, PackedByteArray([0xcc, 0x80]), "BV-P05")
_assert_encodes_to(129, PackedByteArray([0xcc, 0x81]), "BV-P06")
_assert_encodes_to(254, PackedByteArray([0xcc, 0xfe]), "BV-P07")
_assert_encodes_to(255, PackedByteArray([0xcc, 0xff]), "BV-P08")
func test_encode_int16_gdscript() -> void:
# BV-P09 to BV-P12: GDScript encodes 256-32767 as int_16 (NOT uint_16)
# Asymmetry: Rust encodes these as uint_16 (0xcd). Both are spec-valid.
# Format: 0xd1 + 2 bytes big-endian signed
_assert_encodes_to(256, PackedByteArray([0xd1, 0x01, 0x00]), "BV-P09")
_assert_encodes_to(257, PackedByteArray([0xd1, 0x01, 0x01]), "BV-P10")
_assert_encodes_to(32766, PackedByteArray([0xd1, 0x7f, 0xfe]), "BV-P11")
_assert_encodes_to(32767, PackedByteArray([0xd1, 0x7f, 0xff]), "BV-P12")
func test_encode_uint16() -> void:
# BV-P13 to BV-P16: uint 16 range (32768 to 65535)
# Format: 0xcd + 2 bytes big-endian unsigned
_assert_encodes_to(32768, PackedByteArray([0xcd, 0x80, 0x00]), "BV-P13")
_assert_encodes_to(32769, PackedByteArray([0xcd, 0x80, 0x01]), "BV-P14")
_assert_encodes_to(65534, PackedByteArray([0xcd, 0xff, 0xfe]), "BV-P15")
_assert_encodes_to(65535, PackedByteArray([0xcd, 0xff, 0xff]), "BV-P16")
func test_encode_int32_gdscript() -> void:
# BV-P17 to BV-P20: GDScript encodes 65536-2147483647 as int_32 (NOT uint_32)
# Asymmetry: Rust encodes these as uint_32 (0xce). Both are spec-valid.
# Format: 0xd2 + 4 bytes big-endian signed
_assert_encodes_to(65536, PackedByteArray([0xd2, 0x00, 0x01, 0x00, 0x00]), "BV-P17")
_assert_encodes_to(65537, PackedByteArray([0xd2, 0x00, 0x01, 0x00, 0x01]), "BV-P18")
_assert_encodes_to(2147483646, PackedByteArray([0xd2, 0x7f, 0xff, 0xff, 0xfe]), "BV-P19")
_assert_encodes_to(2147483647, PackedByteArray([0xd2, 0x7f, 0xff, 0xff, 0xff]), "BV-P20")
func test_encode_uint32() -> void:
# BV-P21 to BV-P23: uint 32 range (2^31 to 2^32-1)
# Format: 0xce + 4 bytes big-endian unsigned
_assert_encodes_to(2147483648, PackedByteArray([0xce, 0x80, 0x00, 0x00, 0x00]), "BV-P21")
_assert_encodes_to(4294967294, PackedByteArray([0xce, 0xff, 0xff, 0xff, 0xfe]), "BV-P22")
_assert_encodes_to(4294967295, PackedByteArray([0xce, 0xff, 0xff, 0xff, 0xff]), "BV-P23")
func test_encode_int64_positive() -> void:
# BV-P24 to BV-P25: positive values > uint_32 max → uint_64 (0xcf)
# Positive values correctly use uint_64 encoding for Rust interop.
_assert_encodes_to(4294967296, PackedByteArray([
0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00
]), "BV-P24")
_assert_encodes_to(9223372036854775807, PackedByteArray([
0xcf, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
]), "BV-P25")
# -- Negative boundaries: encode-only -----------------------------------------
func test_encode_neg_fixint() -> void:
# BV-N01 to BV-N03: negative fixint range (-1 to -32)
# Format: single byte, two's complement
_assert_encodes_to(-1, PackedByteArray([0xff]), "BV-N01")
_assert_encodes_to(-31, PackedByteArray([0xe1]), "BV-N02")
_assert_encodes_to(-32, PackedByteArray([0xe0]), "BV-N03")
func test_encode_int8_negative() -> void:
# BV-N04 to BV-N07: int 8 range (-33 to -128)
# Format: 0xd0 + 1 byte signed
_assert_encodes_to(-33, PackedByteArray([0xd0, 0xdf]), "BV-N04")
_assert_encodes_to(-34, PackedByteArray([0xd0, 0xde]), "BV-N05")
_assert_encodes_to(-127, PackedByteArray([0xd0, 0x81]), "BV-N06")
_assert_encodes_to(-128, PackedByteArray([0xd0, 0x80]), "BV-N07")
func test_encode_int16_negative() -> void:
# BV-N08 to BV-N11: int 16 range (-129 to -32768)
# Format: 0xd1 + 2 bytes big-endian signed
_assert_encodes_to(-129, PackedByteArray([0xd1, 0xff, 0x7f]), "BV-N08")
_assert_encodes_to(-130, PackedByteArray([0xd1, 0xff, 0x7e]), "BV-N09")
_assert_encodes_to(-32767, PackedByteArray([0xd1, 0x80, 0x01]), "BV-N10")
_assert_encodes_to(-32768, PackedByteArray([0xd1, 0x80, 0x00]), "BV-N11")
func test_encode_int32_negative() -> void:
# BV-N12 to BV-N14: int 32 range (-32769 to -2147483648)
# Format: 0xd2 + 4 bytes big-endian signed
_assert_encodes_to(-32769, PackedByteArray([0xd2, 0xff, 0xff, 0x7f, 0xff]), "BV-N12")
_assert_encodes_to(-2147483647, PackedByteArray([0xd2, 0x80, 0x00, 0x00, 0x01]), "BV-N13")
_assert_encodes_to(-2147483648, PackedByteArray([0xd2, 0x80, 0x00, 0x00, 0x00]), "BV-N14")
func test_encode_int64_negative() -> void:
# BV-N15 to BV-N16: int 64 range (< -2147483648)
# Negative values beyond int_32 now correctly encode as int_64 (0xd3).
# Fixed: encoder's int_64 branch used `-(1 << 63)` which overflowed to dead code.
_assert_encodes_to(-2147483649, PackedByteArray([
0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
]), "BV-N15")
# MIN_INT64: -9223372036854775808
var min_int64: int = -9223372036854775807 - 1
_assert_encodes_to(min_int64, PackedByteArray([
0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]), "BV-N16")
# -- Roundtrip tests -----------------------------------------------------------
func test_roundtrip_positive_boundaries() -> void:
# All 25 positive boundary values: encode -> decode -> assert equal
var values: Array[int] = [
# pos fixint
0, 1, 126, 127,
# uint 8
128, 129, 254, 255,
# int 16 (GDScript) / uint 16 (Rust)
256, 257, 32766, 32767,
# uint 16
32768, 32769, 65534, 65535,
# int 32 (GDScript) / uint 32 (Rust)
65536, 65537, 2147483646, 2147483647,
# uint 32
2147483648, 4294967294, 4294967295,
# int 64 / uint 64
4294967296, 9223372036854775807,
]
for i in values.size():
_assert_roundtrip(values[i], "BV-P%02d" % (i + 1))
func test_roundtrip_negative_boundaries() -> void:
# All 16 negative boundary values: encode -> decode -> assert equal
var min_int64: int = -9223372036854775807 - 1
var values: Array[int] = [
# neg fixint
-1, -31, -32,
# int 8
-33, -34, -127, -128,
# int 16
-129, -130, -32767, -32768,
# int 32
-32769, -2147483647, -2147483648,
# int 64 / uint 64
-2147483649, min_int64,
]
for i in values.size():
_assert_roundtrip(values[i], "BV-N%02d" % (i + 1))
# -- Encoding overlap zone: GDScript decodes Rust-style unsigned encodings -----
# Rust encodes 256-32767 as uint_16 (0xcd) and 65536-2147483647 as uint_32 (0xce).
# GDScript encodes these as int_16 (0xd1) and int_32 (0xd2) respectively.
# Both are valid MessagePack. Both decoders must accept the other side's encoding.
# These tests verify GDScript's decoder handles Rust-style unsigned encodings.
func test_decode_rust_uint16_256() -> void:
# Rust encodes 256 as uint_16: [0xcd, 0x01, 0x00]
var bytes := PackedByteArray([0xcd, 0x01, 0x00])
var result = Messagepack.decode(bytes)
assert_that(result.value).is_equal(256)
func test_decode_rust_uint16_32767() -> void:
# Rust encodes 32767 as uint_16: [0xcd, 0x7f, 0xff]
var bytes := PackedByteArray([0xcd, 0x7f, 0xff])
var result = Messagepack.decode(bytes)
assert_that(result.value).is_equal(32767)
func test_decode_rust_uint32_65536() -> void:
# Rust encodes 65536 as uint_32: [0xce, 0x00, 0x01, 0x00, 0x00]
var bytes := PackedByteArray([0xce, 0x00, 0x01, 0x00, 0x00])
var result = Messagepack.decode(bytes)
assert_that(result.value).is_equal(65536)
func test_decode_rust_uint32_2147483647() -> void:
# Rust encodes 2^31-1 as uint_32: [0xce, 0x7f, 0xff, 0xff, 0xff]
var bytes := PackedByteArray([0xce, 0x7f, 0xff, 0xff, 0xff])
var result = Messagepack.decode(bytes)
assert_that(result.value).is_equal(2147483647)
@@ -0,0 +1 @@
uid://bka1i18dfqay1
+229
View File
@@ -0,0 +1,229 @@
## Client P0 regression tests: guards for Bug #5 (monologue lost) and Bug #2 (camera drift).
## These must pass before any other client testing is meaningful.
##
## Bug #5: Monologue text lost when server sends snapshots faster than client
## consumes them. Fix: carry-forward one-shot events in receive_bytes().
## Bug #2: Camera doesn't center at startup / drifts during pause. Fix: anchor
## pattern with smoothing disabled until first snapshot applied.
##
## Spec ref: stig-round3.md Section 1 (P0 tests #1, #2).
class_name TestP0Regressions
extends GdUnitTestSuite
var _instance: Node = null
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.game_time = {}
GameState.pending_recognitions = []
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
## Encode a minimal valid snapshot as MessagePack bytes.
## Protocol.decode_snapshot() requires: tick, version, entities (with kind as
## bare string for unit enum variants per rmp_serde wire format).
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
var snapshot := {
"tick": overrides.get("tick", 1),
"version": Protocol.PROTOCOL_VERSION,
"entities": overrides.get("entities", [{
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": "Player",
"visibility": "Forward",
}]),
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": overrides.get("tick_rate", "Full"),
},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"visible_tiles": [],
"nearby_interactions": [],
"pending_recognitions": [],
}
# Only include current_monologue if explicitly provided (null omitted)
if overrides.has("current_monologue"):
snapshot["current_monologue"] = overrides["current_monologue"]
if overrides.has("current_dialogue"):
snapshot["current_dialogue"] = overrides["current_dialogue"]
var result = Messagepack.encode(snapshot)
return result.value
# -- Bug #5: Monologue carry-forward ------------------------------------------
# The server sends one-shot monologue in a snapshot. If the server sends another
# snapshot (without monologue) before the client polls, the old snapshot is
# overwritten. The carry-forward logic in receive_bytes() must preserve the
# monologue from the overwritten snapshot.
func test_monologue_not_lost_on_snapshot_overwrite() -> void:
# Snapshot 1: server sends monologue
var bytes_with_mono := _make_snapshot_bytes({
"tick": 1,
"current_monologue": {
"id": "test_mono_001",
"text": "Something about this manifest doesn't add up.",
"duration_seconds": 5.0,
},
})
SimBridge.receive_bytes(bytes_with_mono)
# Snapshot 2: server sends next tick WITHOUT monologue (overwrite scenario)
var bytes_without_mono := _make_snapshot_bytes({
"tick": 2,
})
SimBridge.receive_bytes(bytes_without_mono)
# Assert: _last_snapshot must still carry the monologue from snapshot 1.
# This is the carry-forward fix for Bug #5.
assert_that(SimBridge._last_snapshot).is_not_null()
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
assert_that(mono).is_not_null()
assert_that(mono is Dictionary).is_true()
assert_that(mono.get("text")).is_equal("Something about this manifest doesn't add up.")
func test_monologue_not_duplicated_after_consumption() -> void:
# After the client consumes a carried-forward monologue, the next snapshot
# without monologue should NOT carry forward again (it was already consumed).
var bytes_with_mono := _make_snapshot_bytes({
"tick": 1,
"current_monologue": {
"id": "test_mono_002",
"text": "The corridors feel different at night.",
"duration_seconds": 3.0,
},
})
SimBridge.receive_bytes(bytes_with_mono)
# Client polls and consumes the monologue.
# In live mode, poll_snapshot() returns _last_snapshot and sets it to null.
# In test mode, poll_snapshot() returns _test_snapshot() instead, so we
# simulate the live-mode consumption path directly.
var snapshot = SimBridge._last_snapshot
SimBridge._last_snapshot = null
assert_that(snapshot).is_not_null()
GameState.apply_snapshot(snapshot)
# apply_snapshot sets current_monologue, then main._consume_monologue() clears it.
# Simulate consumption:
GameState.current_monologue = null
# Next snapshot arrives without monologue — _last_snapshot was null after poll,
# so no carry-forward should happen.
var bytes_next := _make_snapshot_bytes({"tick": 2})
SimBridge.receive_bytes(bytes_next)
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
assert_that(mono).is_null()
func test_monologue_carry_forward_preserves_newest() -> void:
# If two snapshots both have monologue, the second one wins (no accumulation).
var bytes_mono1 := _make_snapshot_bytes({
"tick": 1,
"current_monologue": {
"id": "first",
"text": "First thought.",
"duration_seconds": 3.0,
},
})
SimBridge.receive_bytes(bytes_mono1)
var bytes_mono2 := _make_snapshot_bytes({
"tick": 2,
"current_monologue": {
"id": "second",
"text": "Second thought.",
"duration_seconds": 3.0,
},
})
SimBridge.receive_bytes(bytes_mono2)
# Second monologue should win — latest snapshot takes precedence.
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
assert_that(mono).is_not_null()
assert_that(mono.get("text")).is_equal("Second thought.")
# -- Bug #2: Camera static during pause ---------------------------------------
# During pause, the server sends snapshots with unchanged player position.
# The camera must remain at the same position — no drift, no snap to origin.
func test_camera_static_during_pause() -> void:
# 1. Instantiate main scene — camera anchors at test mode player position
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE # (320, 320)
# 2. Verify camera is anchored at expected position
assert_that(camera.global_position).is_equal(expected_pos)
assert_that(_instance._camera_anchored).is_true()
# 3. Set game state to Paused
GameState.game_time = {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": "Paused",
}
# 4. Process a frame — in test mode, poll_snapshot returns same position
# (no movement inputs queued), simulating server pause behavior
_instance._process(0.016)
# 5. Camera must remain at the same position (no drift)
assert_that(camera.global_position).is_equal(expected_pos)
func test_camera_anchored_after_pause_unpause() -> void:
# Verify camera stays properly anchored through a pause → unpause cycle.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
var camera: Camera2D = _instance.get_node("Camera2D")
var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE
# Process several frames with Paused tick rate
GameState.game_time.tick_rate = "Paused"
for i in 5:
_instance._process(0.016)
assert_that(camera.global_position).is_equal(expected_pos)
assert_that(_instance._camera_anchored).is_true()
# Unpause and process — camera should track the (potentially moved) player
GameState.game_time.tick_rate = "Full"
_instance._process(0.016)
# Camera still tracking player (position may have changed due to test_snapshot)
var player_pos := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).is_equal(player_pos)
+1
View File
@@ -0,0 +1 @@
uid://br7kv0dyk3w2k

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