Compare commits

...
55 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 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
201 changed files with 19626 additions and 118 deletions
+5 -1
View File
@@ -178,7 +178,11 @@ For design patterns, consult:
**Writing guidelines:** Always use imperative/infinitive form. **Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:** **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 - `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body). specific triggers/contexts. All "when to use" info goes here (not in body).
+19
View File
@@ -103,6 +103,25 @@ tea pr create \
Report PR URL when done. 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 ## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the 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 - File path and approximate location
- Severity: critical / warning / suggestion - Severity: critical / warning / suggestion
- Description of the issue - 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. If no issues found, say APPROVE with a brief positive summary.
``` ```
+78 -10
View File
@@ -1,10 +1,12 @@
--- ---
name: start-sprint name: start-sprint
description: > description: >
Start sprint work on a team branch. Use when the user says "start sprint", Start sprint work on a team branch, or close a sprint from main. Use when
"start working on the server/client/copy", "begin sprint", or invokes the user says "start sprint", "start working on the server/client/copy",
/start-sprint. Merges main into the team branch, finds the active sprint, "begin sprint", or invokes /start-sprint. On a team branch: merges main,
reads the sprint briefing, and presents the work plan with ticket details. 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 user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion 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`. 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 ### 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 Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work. 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 - Sprint name and goal
- Branch status (clean merge or conflicts) - 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) - Blocked tickets (and what blocks them)
- Key decisions loaded - Key decisions loaded
- Suggested first task (lowest ID unblocked ticket) - Suggested first task (lowest ID unblocked ticket)
Do NOT mark any ticket as `in_progress` yet.
### 8. Confirm and spawn the team ### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and Before spawning agents, use `AskUserQuestion` to confirm the work plan and
+54 -3
View File
@@ -6,11 +6,62 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased] ## [Unreleased]
### Added ## [v0.1.9] — 2026-02-18
- 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 ### 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 ### 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) - `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` - 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) - 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)
+74 -8
View File
@@ -4,7 +4,9 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
decisions-sync decisions-coverage decisions-active decisions-orphan \ 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 pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
# --- Configuration --- # --- Configuration ---
@@ -37,6 +39,11 @@ help:
@echo " make validate-content Validate content YAML against schemas" @echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs" @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 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 ""
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)" @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-server Server-scoped pre-PR (lint, build, test, fixtures)"
@@ -63,6 +70,8 @@ setup-tooling:
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; } @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 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; } @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: setup-hooks:
@git config core.hooksPath .config/hooks @git config core.hooksPath .config/hooks
@@ -77,7 +86,7 @@ build-server:
build-client: build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } @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 --- # --- Run ---
@@ -88,7 +97,7 @@ client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --path client $(GODOT) --path client
game: stop build-server game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..." @echo "Starting server..."
@cd server && cargo run & @cd server && cargo run &
@@ -111,6 +120,41 @@ test-server:
fixtures: fixtures:
cd server && cargo test --test gen_fixtures -- --ignored 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-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } @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/ $(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
@@ -148,13 +192,13 @@ pre-pr-validate: validate-content check-fact-ids
@echo "--- Content validation: PASS ---" @echo "--- Content validation: PASS ---"
pre-pr-fixtures: pre-pr-fixtures:
@echo "Checking fixture staleness..." @echo "Checking Rust->GDScript fixture staleness..."
@cd server && cargo test --test gen_fixtures -- --ignored @cd server && cargo test --test gen_fixtures -- --ignored
@if git diff --quiet client/tests/fixtures/; then \ @if git diff --quiet client/tests/fixtures/; then \
echo "--- Fixtures: UP TO DATE ---"; \ echo "--- Rust fixtures: UP TO DATE ---"; \
else \ else \
echo ""; \ echo ""; \
echo "--- FIXTURES STALE ---"; \ echo "--- RUST FIXTURES STALE ---"; \
echo " Protocol changed but fixtures not regenerated."; \ echo " Protocol changed but fixtures not regenerated."; \
echo " Stale fixtures make all client tests FALSE POSITIVES."; \ echo " Stale fixtures make all client tests FALSE POSITIVES."; \
echo ""; \ echo ""; \
@@ -164,6 +208,22 @@ pre-pr-fixtures:
echo " Fix: commit the updated fixtures with your protocol change."; \ echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \ exit 1; \
fi 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) # Branch-specific variants (faster, scope-appropriate)
@@ -173,7 +233,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures
pre-pr-client: lint-client build-client test-client pre-pr-client: lint-client build-client test-client
@echo "=== Client pre-PR: PASSED ===" @echo "=== Client pre-PR: PASSED ==="
pre-pr-content: validate-content check-fact-ids pre-pr-content: validate-content check-fact-ids checklist-validate
@echo "=== Content pre-PR: PASSED ===" @echo "=== Content pre-PR: PASSED ==="
# --- CI (run locally) --- # --- CI (run locally) ---
@@ -214,6 +274,12 @@ validate-content:
check-fact-ids: check-fact-ids:
@tooling/check-fact-ids @tooling/check-fact-ids
checklist-validate:
@tooling/validate-checklist --check
checklist-generate:
@tooling/validate-checklist
content-ron: content-ron:
cd tooling/content-converter && cargo build --release cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -223,5 +289,5 @@ content-ron:
clean: clean:
cd server && cargo clean || true cd server && cargo clean || true
rm -rf .cache/* rm -rf .cache/*
rm -rf client/.godot client/reports rm -rf client/.godot/* client/reports
@echo "Clean complete." @echo "Clean complete."
+11 -4
View File
@@ -87,7 +87,8 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
elif 0 <= value and value <= (1 << 32) - 1: elif 0 <= value and value <= (1 << 32) - 1:
buffer.put_u8(types["uint_32"]) buffer.put_u8(types["uint_32"])
buffer.put_u32(value) 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_u8(types["int_64"])
buffer.put_64(value) buffer.put_64(value)
else: else:
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA return ERR_INVALID_DATA
for obj in value: for obj in value:
_encode_message(buffer, obj) var inner_err = _encode_message(buffer, obj)
if inner_err:
return inner_err
TYPE_DICTIONARY: TYPE_DICTIONARY:
var size = value.size() var size = value.size()
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA return ERR_INVALID_DATA
for key in value: for key in value:
_encode_message(buffer, key) var key_err = _encode_message(buffer, key)
_encode_message(buffer, value[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: TYPE_PACKED_BYTE_ARRAY:
var size = value.size() var size = value.size()
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
+5
View File
@@ -106,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) "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] [rendering]
+9 -2
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=17 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/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -16,6 +16,8 @@
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"] [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="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"] [ext_resource type="Script" path="res://scripts/rendering/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"] [node name="Game" type="Node2D"]
script = ExtResource("1_main") script = ExtResource("1_main")
@@ -131,6 +133,9 @@ layer = 20
; D-053: Stance indicator — top-right, color-coded ; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")] [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 ; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -140,6 +145,8 @@ script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) --- ; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen. ; 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="."] [node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30 layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
@@ -0,0 +1 @@
uid://cdkut8u7sxwmb
@@ -0,0 +1 @@
uid://de816lfn5xeqs
+14
View File
@@ -36,6 +36,10 @@ var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, option
# InputMapper suppresses movement when this is true. # InputMapper suppresses movement when this is true.
var dialogue_active: bool = false 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) # v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -118,6 +122,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else: else:
pending_recognitions = [] 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 # v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode) # Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+3
View File
@@ -14,6 +14,7 @@ enum Action {
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST, MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
} }
var input_queue: Array[Dictionary] = [] var input_queue: Array[Dictionary] = []
@@ -74,6 +75,8 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_UP action = Action.TOGGLE_STANCE_UP
elif event.is_action_pressed("stance_down"): elif event.is_action_pressed("stance_down"):
action = Action.TOGGLE_STANCE_DOWN action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
if action != -1: if action != -1:
input_queue.append({ input_queue.append({
+3 -3
View File
@@ -255,9 +255,9 @@ static func _action_enum_to_wire(action: int) -> String:
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU: InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol return "" # Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server") InputMapper.Action.BUG_REPORT:
return "" return "" # Client-only action (#495), not part of wire protocol
_: _:
push_warning("SimBridge: unknown action enum %s" % action) push_warning("SimBridge: unknown action enum %s" % action)
return "" return ""
+21
View File
@@ -12,6 +12,8 @@ extends Node2D
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7 @onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: 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 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 _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false var _camera_anchored: bool = false
@@ -49,6 +51,10 @@ func _ready() -> void:
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue) 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: func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input # Main game loop: poll snapshot, apply state, flush input
@@ -88,6 +94,10 @@ func _process(_delta: float) -> void:
if fog_entities and fog_entities.has_method("update_from_state"): if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.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) # Show monologue if server sent one this tick (#414)
_consume_monologue() _consume_monologue()
@@ -109,6 +119,11 @@ func _process(_delta: float) -> void:
# Send queued input to simulation # Send queued input to simulation
var inputs = InputMapper.flush_queue() var inputs = InputMapper.flush_queue()
for input in inputs: 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: if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1 var target_id: int = -1
@@ -197,3 +212,9 @@ func _on_dialogue_dismissed() -> void:
"verb": "WalkAway", "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()
+27 -14
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value. ## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 7 const PROTOCOL_VERSION: int = 8
# -- Decode: bytes from server → GDScript types -------------------------------- # -- Decode: bytes from server → GDScript types --------------------------------
@@ -169,6 +169,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"options": dialogue_options, "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 { return {
"tick": tick, "tick": tick,
"entities": entities, "entities": entities,
@@ -182,6 +193,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"nearby_interactions": nearby_interactions, "nearby_interactions": nearby_interactions,
"current_monologue": current_monologue, "current_monologue": current_monologue,
"current_dialogue": current_dialogue, "current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions, "pending_recognitions": pending_recognitions,
} }
@@ -293,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
## "Interact", "UsePerceptionMode", "Pause", "Unpause" ## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode ## action_data: null for unit variants, String for UsePerceptionMode
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray: static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action_value: Variant var action_value: Variant = _encode_action(action_name, action_data)
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 input := { var input := {
"tick": tick, "tick": tick,
@@ -322,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
for input in inputs: for input in inputs:
var action_name: String = input["action_name"] var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data") 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({ wire_inputs.append({
"tick": input["tick"], "tick": input["tick"],
"action": action_value, "action": _encode_action(action_name, action_data),
}) })
var result = Messagepack.encode(wire_inputs) var result = Messagepack.encode(wire_inputs)
@@ -340,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
return result.value 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). ## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null. ## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant: static func decode_player_input(bytes: PackedByteArray) -> Variant:
@@ -0,0 +1 @@
uid://bkbfcalfo1kk8
@@ -0,0 +1 @@
uid://cw154tgjdyhe1
@@ -0,0 +1 @@
uid://dje4vilxh5xxu
+1
View File
@@ -0,0 +1 @@
uid://bcc1wwpk3moh1
+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
+1
View File
@@ -0,0 +1 @@
uid://vw0iqhai1kum
+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 -- # -- 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 inputs: Array = [{"tick": 100, "action_name": "Interact"}]
var bytes := Protocol.encode_player_inputs(inputs) var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes) var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true() 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: func test_interact_with_data_encodes_as_data_variant() -> void:
# Future: once server accepts Interact(InteractData)
var inputs: Array = [{ var inputs: Array = [{
"tick": 100, "tick": 100,
"action_name": "Interact", "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 is Array).is_true()
assert_that(raw.value.size()).is_equal(2) assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth") 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 -------------------------------------------- # -- Diagonal movement wire mapping --------------------------------------------
+6 -12
View File
@@ -85,13 +85,8 @@ func test_encode_uint32() -> void:
func test_encode_int64_positive() -> void: func test_encode_int64_positive() -> void:
# BV-P24 to BV-P25: int 64 range (2^32 to 2^63-1) # BV-P24 to BV-P25: positive values > uint_32 max → uint_64 (0xcf)
# KNOWN-DEFECT: The encoder's int_64 branch condition `-(1 << 63) <= v < (1 << 63)` # Positive values correctly use uint_64 encoding for Rust interop.
# evaluates to `MIN_INT64 <= v < MIN_INT64` due to overflow, making it dead code.
# Values that should be int_64 (0xd3) are instead encoded as uint_64 (0xcf).
# Roundtrip still works because put_u64/get_u64 preserve the bit pattern.
# This test documents ACTUAL behavior. Fix tracked in backlog.
# See: messagepack.gd int_64 branch — use explicit constant instead of `1 << 63`.
_assert_encodes_to(4294967296, PackedByteArray([ _assert_encodes_to(4294967296, PackedByteArray([
0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00
]), "BV-P24") ]), "BV-P24")
@@ -138,16 +133,15 @@ func test_encode_int32_negative() -> void:
func test_encode_int64_negative() -> void: func test_encode_int64_negative() -> void:
# BV-N15 to BV-N16: int 64 range (< -2147483648) # BV-N15 to BV-N16: int 64 range (< -2147483648)
# KNOWN-DEFECT: Same int_64 branch issue as positive int_64 — encoded as uint_64 (0xcf). # Negative values beyond int_32 now correctly encode as int_64 (0xd3).
# Bit pattern is preserved: put_u64(negative) writes two's complement, # Fixed: encoder's int_64 branch used `-(1 << 63)` which overflowed to dead code.
# get_u64() reads it back and Variant stores as int64 with same bit pattern.
_assert_encodes_to(-2147483649, PackedByteArray([ _assert_encodes_to(-2147483649, PackedByteArray([
0xcf, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff 0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
]), "BV-N15") ]), "BV-N15")
# MIN_INT64: -9223372036854775808 # MIN_INT64: -9223372036854775808
var min_int64: int = -9223372036854775807 - 1 var min_int64: int = -9223372036854775807 - 1
_assert_encodes_to(min_int64, PackedByteArray([ _assert_encodes_to(min_int64, PackedByteArray([
0xcf, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]), "BV-N16") ]), "BV-N16")
@@ -0,0 +1 @@
uid://bka1i18dfqay1
+7 -3
View File
@@ -119,15 +119,19 @@ func test_monologue_not_duplicated_after_consumption() -> void:
}) })
SimBridge.receive_bytes(bytes_with_mono) SimBridge.receive_bytes(bytes_with_mono)
# Client polls and consumes the monologue # Client polls and consumes the monologue.
var snapshot = SimBridge.poll_snapshot() # 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() assert_that(snapshot).is_not_null()
GameState.apply_snapshot(snapshot) GameState.apply_snapshot(snapshot)
# apply_snapshot sets current_monologue, then main._consume_monologue() clears it. # apply_snapshot sets current_monologue, then main._consume_monologue() clears it.
# Simulate consumption: # Simulate consumption:
GameState.current_monologue = null GameState.current_monologue = null
# Next snapshot arrives without monologue — _last_snapshot is null after poll, # Next snapshot arrives without monologue — _last_snapshot was null after poll,
# so no carry-forward should happen. # so no carry-forward should happen.
var bytes_next := _make_snapshot_bytes({"tick": 2}) var bytes_next := _make_snapshot_bytes({"tick": 2})
SimBridge.receive_bytes(bytes_next) SimBridge.receive_bytes(bytes_next)
+1
View File
@@ -0,0 +1 @@
uid://br7kv0dyk3w2k
+6 -2
View File
@@ -244,7 +244,9 @@ func test_encode_player_inputs_multiple() -> void:
assert_that(raw.status).is_null() assert_that(raw.status).is_null()
assert_that(raw.value.size()).is_equal(3) assert_that(raw.value.size()).is_equal(3)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth") 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()
assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest") assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest")
@@ -358,7 +360,9 @@ func test_decode_batch_input_fixture() -> void:
assert_that(raw.value[0]["tick"]).is_equal(0) assert_that(raw.value[0]["tick"]).is_equal(0)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth") assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["tick"]).is_equal(0) assert_that(raw.value[1]["tick"]).is_equal(0)
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 fixtures (D-030 Layer 1 cross-language) ----------------- # -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
+7 -7
View File
@@ -26,17 +26,17 @@ func _load_fixture(name: String) -> PackedByteArray:
# -- Protocol version upgrade ------------------------------------------------- # -- Protocol version upgrade -------------------------------------------------
func test_protocol_version_is_7() -> void: func test_protocol_version_is_8() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(7) assert_that(Protocol.PROTOCOL_VERSION).is_equal(8)
func test_fixtures_at_protocol_version_7() -> void: func test_fixtures_at_protocol_version_8() -> void:
# All regenerated fixtures should be at v7 # All regenerated fixtures should be at v8
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]: for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
var bytes = _load_fixture(fixture_name) var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes) var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null() assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(7) assert_that(snapshot.version).is_equal(8)
func test_rejects_version_6() -> void: func test_rejects_version_6() -> void:
@@ -282,10 +282,10 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
assert_that(snap.player_inventory is Array).is_true() assert_that(snap.player_inventory is Array).is_true()
func test_sim_bridge_test_snapshot_version_7() -> void: func test_sim_bridge_test_snapshot_version_8() -> void:
SimBridge.reset_test_state() SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot() var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(7) assert_that(snap.version).is_equal(8)
# -- Fixture: v6 snapshots include new fields ---------------------------------- # -- Fixture: v6 snapshots include new fields ----------------------------------
+1
View File
@@ -0,0 +1 @@
uid://cefjgd1iqi7qn
+1
View File
@@ -0,0 +1 @@
uid://bjd6g15syl2qy
+206
View File
@@ -0,0 +1,206 @@
extends Control
## #495: WRONG button (F12) MVP — bug report capture dialog.
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
signal capture_completed
signal capture_cancelled
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color("#c8d0e0")
const FONT_SIZE := 14
const LABEL_FONT_SIZE := 13
const BOX_WIDTH := 500
const BOX_HEIGHT := 120
const PADDING := 16
var _line_edit: LineEdit = null
var _active: bool = false
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func start_capture() -> void:
if _active:
return
_active = true
visible = true
# Pause the simulation
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
# Create the LineEdit dynamically
_line_edit = LineEdit.new()
_line_edit.placeholder_text = "Describe the issue..."
_line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30)
_line_edit.position = Vector2(
(get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING,
(get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50
)
_line_edit.add_theme_font_size_override("font_size", FONT_SIZE)
_line_edit.text_submitted.connect(_on_text_submitted)
add_child(_line_edit)
_line_edit.grab_focus()
func _on_text_submitted(text: String) -> void:
_save_report(text)
_close()
capture_completed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not _active:
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_close()
capture_cancelled.emit()
get_viewport().set_input_as_handled()
func _close() -> void:
_active = false
visible = false
if _line_edit:
_line_edit.queue_free()
_line_edit = null
# Unpause the simulation
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func _save_report(description: String) -> void:
var tick := GameState.current_tick
var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
var dir_name := "gauntlet-t%d-%s" % [tick, timestamp]
var base_path := "user://bug-reports/" + dir_name
# Ensure directory exists
var dir_err := DirAccess.make_dir_recursive_absolute(base_path)
if dir_err != OK:
push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err])
return
var files_saved := 0
# 1. snapshot.json — full current snapshot as JSON
var snapshot_path := base_path + "/snapshot.json"
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
if snapshot_file:
snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t"))
snapshot_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snapshot_path)
# 2. render.txt — simplified client-side text render of snapshot
var render_path := base_path + "/render.txt"
var render_file := FileAccess.open(render_path, FileAccess.WRITE)
if render_file:
render_file.store_string(_render_snapshot_text())
render_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % render_path)
# 3. description.txt — tester description + metadata
var desc_path := base_path + "/description.txt"
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
if desc_file:
desc_file.store_string("Description: %s\n" % description)
desc_file.store_string("Tick: %d\n" % tick)
desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none"))
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
## Simplified client-side text render of the current snapshot.
## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal.
func _render_snapshot_text() -> String:
var lines: PackedStringArray = []
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
lines.append("Player: %s facing %s (%s)" % [
str(GameState.player_position), GameState.player_facing, GameState.player_stance])
if GameState.game_time.size() > 0:
lines.append("Time: day %s, %s, %s" % [
str(GameState.game_time.get("day", "?")),
str(GameState.game_time.get("day_phase", "?")),
str(GameState.game_time.get("tick_rate", "?"))])
lines.append("")
lines.append("Entities (%d):" % GameState.visible_entities.size())
for entity in GameState.visible_entities:
var kind_str: String = ""
if entity.has("kind") and entity.kind is Dictionary:
kind_str = entity.kind.get("variant", "?")
elif entity.has("kind") and entity.kind is String:
kind_str = entity.kind
var vis: String = entity.get("visibility", "?")
lines.append(" #%s %s at (%s, %s) [%s]" % [
str(entity.get("entity_id", "?")),
kind_str,
str(entity.get("x", "?")),
str(entity.get("y", "?")),
vis])
lines.append("")
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
if GameState.current_monologue != null:
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
if GameState.current_dialogue != null:
lines.append("Dialogue: %s says '%s'" % [
str(GameState.current_dialogue.get("npc_name", "?")),
str(GameState.current_dialogue.get("speech", ""))])
return "\n".join(lines)
func _draw() -> void:
if not _active:
return
var viewport_size := get_viewport_rect().size
# Full-screen dim
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Center box
var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0,
(viewport_size.y - BOX_HEIGHT) / 2.0
)
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
# Title
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, 24),
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
# -- Public API ---------------------------------------------------------------
func is_active() -> bool:
return _active
+1
View File
@@ -0,0 +1 @@
uid://dwdssbevd8gqq
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/bug_report_dialog.gd" id="1_bugreport"]
; #495: WRONG button (F12) — bug report capture dialog, ModalLayer
[node name="BugReportDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_bugreport")
+1
View File
@@ -0,0 +1 @@
uid://btwe5f7tmm3o4
+182
View File
@@ -0,0 +1,182 @@
extends Control
## #496: Gauntlet HUD — room timer + personal bests.
## Shows TIMER: MM:SS (PB: MM:SS) in top-right, below StanceIndicator.
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json.
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
const FONT_SIZE := 13
const PADDING := Vector2(10, 6)
const STATS_PATH := "user://dev/gauntlet-stats.json"
var _timer_seconds: float = 0.0
var _timer_running: bool = false
var _current_room_id: Variant = null
var _personal_bests: Dictionary = {} # room_id -> float (seconds)
var _session_rooms: Dictionary = {} # room_id -> {attempts: int, best: float}
var _new_pb_flash: float = 0.0 # Countdown for PB flash effect
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_load_stats()
func _process(delta: float) -> void:
if not visible:
return
if _timer_running:
_timer_seconds += delta
queue_redraw()
if _new_pb_flash > 0.0:
_new_pb_flash -= delta
queue_redraw()
func update_from_state() -> void:
if not GameState.gauntlet_mode:
if visible:
visible = false
return
if not visible:
visible = true
queue_redraw()
var new_room_id: Variant = GameState.room_id
if new_room_id == null:
# Gauntlet mode but no room yet — stop timer, clear tracked room
# so re-entry to the same room after null triggers a restart.
_timer_running = false
_current_room_id = null
return
if new_room_id != _current_room_id:
_on_room_change(str(new_room_id))
func _on_room_change(new_room_id: String) -> void:
# Record completion of previous room
if _current_room_id != null and _timer_running:
_record_room_completion(_current_room_id, _timer_seconds)
# Start timer for new room
_current_room_id = new_room_id
_timer_seconds = 0.0
_timer_running = true
# Track session stats
if not _session_rooms.has(new_room_id):
_session_rooms[new_room_id] = {"attempts": 0, "best": INF}
_session_rooms[new_room_id]["attempts"] += 1
queue_redraw()
func _record_room_completion(completed_room_id: String, seconds: float) -> void:
var old_pb: float = _personal_bests.get(completed_room_id, INF)
if seconds < old_pb:
_personal_bests[completed_room_id] = seconds
_new_pb_flash = 2.0 # Flash for 2 seconds
_save_stats()
# Update session tracking
if _session_rooms.has(completed_room_id):
var entry: Dictionary = _session_rooms[completed_room_id]
if seconds < entry["best"]:
entry["best"] = seconds
func _draw() -> void:
var font := ThemeDB.fallback_font
var timer_text := "TIMER: " + _format_time(_timer_seconds)
var pb_text := ""
if _current_room_id != null and _personal_bests.has(_current_room_id):
pb_text = " (PB: " + _format_time(_personal_bests[_current_room_id]) + ")"
var full_text := timer_text + pb_text
var text_size := font.get_string_size(full_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
var box_size := text_size + PADDING * 2
# Background
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
# Timer text
var y_offset := PADDING.y + text_size.y
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR)
# PB text (different color)
if not pb_text.is_empty():
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
static func _format_time(seconds: float) -> String:
var total_secs := int(seconds)
var mins := total_secs / 60
var secs := total_secs % 60
return "%02d:%02d" % [mins, secs]
func _load_stats() -> void:
if not FileAccess.file_exists(STATS_PATH):
return
var file := FileAccess.open(STATS_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
var err := json.parse(file.get_as_text())
file.close()
if err == OK and json.data is Dictionary:
_personal_bests = json.data
func _save_stats() -> void:
var file := FileAccess.open(STATS_PATH, FileAccess.WRITE)
if file == null:
push_warning("GauntletHUD: cannot write stats to %s" % STATS_PATH)
return
file.store_string(JSON.stringify(_personal_bests, "\t"))
file.close()
func print_session_summary() -> void:
if _session_rooms.is_empty():
return
print("=== Gauntlet Session Summary ===")
for room_id in _session_rooms:
var entry: Dictionary = _session_rooms[room_id]
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--"
var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [
room_id, entry["attempts"], best_str, pb_str])
print("================================")
# Record current room if timer is running (called on disconnect)
func finalize() -> void:
if _current_room_id != null and _timer_running:
_record_room_completion(_current_room_id, _timer_seconds)
_timer_running = false
print_session_summary()
# -- Public API ---------------------------------------------------------------
func get_timer_seconds() -> float:
return _timer_seconds
func is_timer_running() -> bool:
return _timer_running
func get_current_room_id() -> Variant:
return _current_room_id
func get_personal_best(for_room_id: String) -> float:
return _personal_bests.get(for_room_id, INF)
+1
View File
@@ -0,0 +1 @@
uid://2dtu0xe0odtf
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"]
; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator
[node name="GauntletHUD" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -160.0
offset_top = 48.0
offset_right = -16.0
offset_bottom = 74.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_gauntlet")
+1
View File
@@ -0,0 +1 @@
uid://wkkt2nhmygtm
+1
View File
@@ -0,0 +1 @@
uid://dhdp146dcog3x
+1
View File
@@ -0,0 +1 @@
uid://7y327ugttryy
+1
View File
@@ -0,0 +1 @@
uid://ct55qobqip5nn
+135
View File
@@ -0,0 +1,135 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "checklist.schema.json",
"title": "Gauntlet QA Checklist",
"description": "Per-room QA checklist with conditions evaluable from ObserverSnapshot (D-030). Feeds into #503 auto-checklist progress tracking.",
"type": "object",
"required": ["conditions"],
"additionalProperties": false,
"properties": {
"room_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Room identifier matching constants.rs room name"
},
"room_name": {
"type": "string",
"minLength": 1,
"description": "Human-readable room name"
},
"scope": {
"type": "string",
"enum": ["per_room", "cross_room"],
"description": "Checklist scope: per_room (default) or cross_room"
},
"description": {
"type": "string"
},
"conditions": {
"type": "array",
"items": {
"oneOf": [
{ "$ref": "#/$defs/player_near" },
{ "$ref": "#/$defs/player_facing" },
{ "$ref": "#/$defs/entity_present" },
{ "$ref": "#/$defs/entity_absent" },
{ "$ref": "#/$defs/expected_monologue" },
{ "$ref": "#/$defs/expected_dialogue" },
{ "$ref": "#/$defs/expected_interaction_verb" }
]
},
"minItems": 1
}
},
"$defs": {
"condition_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$",
"description": "Globally unique condition identifier across all checklists. Use room_id prefix (e.g., inv-item-present, occ-wall-visible)."
},
"player_near": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "x", "y", "radius"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "player_near" },
"x": { "type": "integer", "description": "Target X coordinate (absolute sim tiles)" },
"y": { "type": "integer", "description": "Target Y coordinate (absolute sim tiles)" },
"radius": { "type": "number", "minimum": 0, "description": "Proximity radius in tiles" }
}
},
"player_facing": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "direction"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "player_facing" },
"direction": {
"type": "string",
"enum": ["North", "South", "East", "West"],
"description": "Expected player facing direction"
}
}
},
"entity_present": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "entity_present" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of expected entity" }
}
},
"entity_absent": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "entity_absent" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of entity that should NOT be visible" }
}
},
"expected_monologue": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "contains"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_monologue" },
"contains": { "type": "string", "minLength": 1, "description": "Substring expected in current_monologue text" }
}
},
"expected_dialogue": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "contains"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_dialogue" },
"contains": { "type": "string", "minLength": 1, "description": "Substring expected in dialogue_response text" }
}
},
"expected_interaction_verb": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id", "verb"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_interaction_verb" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of target entity" },
"verb": { "type": "string", "minLength": 1, "description": "Expected interaction verb (e.g., Talk, Pickup, Examine)" }
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
scope: cross_room
description: "Cross-room checks applied to every Gauntlet room visit."
conditions:
- id: cross-player-entity-present
description: "Player entity (StableId 0) always present in snapshot"
condition_type: entity_present
entity_id: 0
@@ -0,0 +1,42 @@
room_id: inventory_warehouse
room_name: Inventory Warehouse
description: "Tests D-065 (9-slot inventory), pickup/drop verbs, CarriedBy component. 10 crates (StableId 13-22) + 1 NPC (StableId 23)."
conditions:
- id: inv-player-spawn
description: "Player starts near warehouse spawn position"
condition_type: player_near
x: 17
y: 54
radius: 2.0
- id: inv-player-facing-east
description: "Player faces east at spawn"
condition_type: player_facing
direction: East
- id: inv-crate-01-visible
description: "First crate (Keycard) is visible from spawn"
condition_type: entity_present
entity_id: 13
- id: inv-crate-10-visible
description: "Last crate (Chip) is visible"
condition_type: entity_present
entity_id: 22
- id: inv-npc-warehouse-visible
description: "Warehouse supervisor NPC is present"
condition_type: entity_present
entity_id: 23
- id: inv-crate-pickup-verb
description: "Pickup verb available on crate when nearby"
condition_type: expected_interaction_verb
entity_id: 13
verb: Pickup
- id: inv-npc-talk-verb
description: "Talk verb available on warehouse NPC"
condition_type: expected_interaction_verb
entity_id: 23
verb: Talk
@@ -0,0 +1,41 @@
room_id: occlusion_corridor
room_name: Occlusion Corridor
description: "Tests D-035 (symmetric shadowcasting), D-017 (perception modes), D-015 (vision cone sectors). 4 NPCs (StableId 9-12)."
conditions:
- id: occ-player-spawn
description: "Player starts at corridor entrance"
condition_type: player_near
x: 84
y: 58
radius: 2.0
- id: occ-player-facing-east
description: "Player faces east down the corridor"
condition_type: player_facing
direction: East
- id: occ-guard-visible
description: "Guard NPC in clear LOS from spawn"
condition_type: entity_present
entity_id: 9
- id: occ-hidden-npc-absent
description: "NPC behind north wall segment is NOT visible from spawn"
condition_type: entity_absent
entity_id: 10
- id: occ-peripheral-npc-visible
description: "South alcove NPC visible in peripheral vision sector"
condition_type: entity_present
entity_id: 11
- id: occ-far-end-visible
description: "Far end NPC visible down corridor"
condition_type: entity_present
entity_id: 12
- id: occ-guard-talk-verb
description: "Talk verb available on guard NPC when nearby"
condition_type: expected_interaction_verb
entity_id: 9
verb: Talk
@@ -0,0 +1,26 @@
room_id: pause_chamber
room_name: Pause Chamber
description: "Tests D-031 (pause/unpause), Bug #3 regression (movement while paused). 1 NPC (StableId 29)."
conditions:
- id: pause-player-spawn
description: "Player starts at chamber center"
condition_type: player_near
x: 50
y: 86
radius: 2.0
- id: pause-player-facing-north
description: "Player faces north at spawn"
condition_type: player_facing
direction: North
- id: pause-npc-present
description: "Pause target NPC is visible"
condition_type: entity_present
entity_id: 29
- id: pause-talk-verb
description: "Talk verb available on NPC (should work while paused)"
condition_type: expected_interaction_verb
entity_id: 29
verb: Talk
+24 -2
View File
@@ -358,7 +358,25 @@ def cmd_stop(args):
cancelled = [t for t in tickets if t["status"] == "cancelled"] cancelled = [t for t in tickets if t["status"] == "cancelled"]
incomplete = [t for t in tickets if t["status"] not in ("done", "cancelled")] incomplete = [t for t in tickets if t["status"] not in ("done", "cancelled")]
# Complete # Auto-close: mark picked-up tickets (in_progress, review) as done.
# Work merged to main before sprint close means the ticket is done —
# agents just forget to update status. Backlog/ready tickets were
# never started, so they stay as carry-over candidates.
picked_up_statuses = ("in_progress", "review")
picked_up = [t for t in incomplete if t["status"] in picked_up_statuses]
auto_closed = []
if picked_up:
conn = get_connection()
for t in picked_up:
conn.execute("UPDATE tickets SET status='done' WHERE id=?", (t["id"],))
auto_closed.append(t)
conn.commit()
conn.close()
# Move auto-closed into done count, remove from incomplete
done = done + auto_closed
incomplete = [t for t in incomplete if t["status"] not in picked_up_statuses]
# Complete the sprint
conn = get_connection() conn = get_connection()
conn.execute( conn.execute(
"UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?", "UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?",
@@ -372,10 +390,14 @@ def cmd_stop(args):
print(f"Done: {len(done)}/{len(tickets)}") print(f"Done: {len(done)}/{len(tickets)}")
if cancelled: if cancelled:
print(f"Cancelled: {len(cancelled)}") print(f"Cancelled: {len(cancelled)}")
if auto_closed:
print(f"Auto-closed: {len(auto_closed)} tickets marked done on sprint close:")
for t in auto_closed:
print(f" #{t['id']}: {t['title']} ({t['status']} → done)")
print() print()
if incomplete: if incomplete:
print("Carry-over candidates (incomplete):") print("Carry-over candidates (never started):")
format_ticket_table(incomplete) format_ticket_table(incomplete)
print() print()
+7 -6
View File
@@ -14,7 +14,7 @@ Usage:
ticket sprint assign <id> <sprint_id> ticket sprint assign <id> <sprint_id>
ticket deps <id> ticket deps <id>
ticket search <keyword> ticket search <keyword>
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
ticket epics [--status S] ticket epics [--status S]
ticket children <id> ticket children <id>
ticket count [--status S] ticket count [--status S]
@@ -256,9 +256,9 @@ def cmd_search(conn, keyword):
def cmd_create(conn, args): def cmd_create(conn, args):
flags, positional = parse_flags(args, ["parent", "priority", "decision", "team"]) flags, positional = parse_flags(args, ["parent", "priority", "decision", "team", "description"])
if len(positional) < 2: if len(positional) < 2:
out({"ok": False, "error": "Usage: ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]"}) out({"ok": False, "error": "Usage: ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]"})
return return
ticket_type = positional[0] ticket_type = positional[0]
title = " ".join(positional[1:]) title = " ".join(positional[1:])
@@ -266,9 +266,10 @@ def cmd_create(conn, args):
priority = flags.get("priority", "medium") priority = flags.get("priority", "medium")
decision_ref = flags.get("decision") decision_ref = flags.get("decision")
team = flags.get("team") team = flags.get("team")
description = flags.get("description")
conn.execute( conn.execute(
"INSERT INTO tickets (type, title, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO tickets (type, title, description, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?, ?)",
(ticket_type, title, parent_id, priority, decision_ref, team)) (ticket_type, title, description, parent_id, priority, decision_ref, team))
conn.commit() conn.commit()
last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"] last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"]
out({"ok": True, "id": last_id, "title": title}) out({"ok": True, "id": last_id, "title": title})
@@ -330,7 +331,7 @@ Usage:
ticket sprint assign <id> <sprint> Assign ticket to sprint ticket sprint assign <id> <sprint> Assign ticket to sprint
ticket deps <id> Show ticket dependencies ticket deps <id> Show ticket dependencies
ticket search <keyword> Search tickets by title/description ticket search <keyword> Search tickets by title/description
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
ticket epics [--status S] List epics with child counts ticket epics [--status S] List epics with child counts
ticket children <id> List children of a ticket ticket children <id> List children of a ticket
ticket count [--status S] Count tickets by status""" ticket count [--status S] Count tickets by status"""
+50
View File
@@ -69,6 +69,43 @@ make test-client # gdUnit4 tests (headless runner pending)
Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030). Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030).
### Cross-Encoder Fixtures
```bash
make fixtures # Regenerate Rust->GDScript fixtures (server/tests/gen_fixtures.rs)
make fixtures-client # Generate GDScript->Rust fixtures + verify Rust decoder (#475)
```
The bidirectional protocol is validated by two sets of committed fixtures:
- **Rust encodes, GDScript decodes:** `client/tests/fixtures/msgpack/` (generated by `make fixtures`)
- **GDScript encodes, Rust decodes:** `server/tests/fixtures/gdscript/` (generated by `make fixtures-client`)
Regenerate both after any protocol change. Commit the updated fixtures alongside the code change.
**Troubleshooting fixture failures:**
- **`make fixtures-client` fails with encode errors:** Check that `client/addons/messagepack/messagepack.gd` is up to date. The script exits non-zero on any encode failure.
- **`gdscript_generated_fixtures_deserialize` fails:** Fixtures in `server/tests/fixtures/gdscript/` are stale or corrupted. Re-run `make fixtures-client` and commit the updated files.
- **Fixture staleness in `make pre-pr`:** Protocol changed but fixtures were not regenerated. Run `make fixtures && make fixtures-client`, then commit both `client/tests/fixtures/` and `server/tests/fixtures/gdscript/`.
### Golden File Management
```bash
make golden-diff # Show diff if golden file output has changed
make golden-update # Regenerate golden file and stage for commit
```
The golden file (`server/tests/golden/proof_room_tick_10.json`) is a committed snapshot of ObserverSnapshot output after a deterministic 10-tick replay. It catches unintentional changes to simulation output.
**Workflow after intentional simulation changes:**
1. Run `make golden-diff` to see what changed
2. Review the diff — confirm changes are expected
3. Run `make golden-update` to regenerate and stage the new golden file
4. Commit the updated golden file alongside your simulation change
`golden-diff` exits 1 if the golden file has changed (useful in scripts). `golden-update` regenerates the file and runs `git add` but does not commit — the developer reviews and commits manually.
### Lint ### Lint
```bash ```bash
@@ -130,6 +167,19 @@ make validate-content # Validate content YAML against JSON schemas
make check-fact-ids # Check fact_id references against knowledge catalogs make check-fact-ids # Check fact_id references against knowledge catalogs
``` ```
### Gauntlet Checklists
```bash
make checklist-validate # Validate checklist YAML against schema (standalone)
make checklist-generate # Validate + print per-room condition summary
```
Checklists live at `content/gauntlet/rooms/{room_id}/checklist.yaml` (per-room) and `content/gauntlet/cross_room_checks.yaml` (cross-room). Each condition is evaluable from an `ObserverSnapshot`.
7 condition types: `player_near`, `player_facing`, `entity_present`, `entity_absent`, `expected_monologue`, `expected_dialogue`, `expected_interaction_verb`.
Schema: `content/_schema/checklist.schema.json`. The checklist format feeds into #503 (client auto-checklist progress tracking).
`check-fact-ids` operates in two modes: `check-fact-ids` operates in two modes:
- **Advisory** — when knowledge catalogs (`content/global/knowledge/*.yaml`) have no fact definitions yet: lists referenced fact_ids and exits cleanly. - **Advisory** — when knowledge catalogs (`content/global/knowledge/*.yaml`) have no fact definitions yet: lists referenced fact_ids and exits cleanly.
- **Enforcing** — when catalogs are populated: fails on any `fact_id` reference that doesn't match a canonical definition. - **Enforcing** — when catalogs are populated: fails on any `fact_id` reference that doesn't match a canonical definition.
+33 -14
View File
@@ -5,18 +5,20 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
## Generation Approach ## Generation Approach
- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`. - **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`.
- **Monologue chimes:** Currently PLACEHOLDER (Sprint 7). Redo scheduled next audio sprint (#453). Design brief below captures target register. - **Monologue chimes:** Re-generated in Sprint 9 (#453). Insert-tech prefix + Chimes template, 5s SAO generation trimmed to 0.8s.
## Assets ## Assets
| ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint | | ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint |
|----|----------|--------|-----|--------|----------|-------------|--------| |----|----------|--------|-----|--------|----------|-------------|--------|
| UI-001 | `cursor_hover.ogg` | draft | UI Sounds | SAO | 50-100ms | Subtle tick/ping on entity hover (D-056). Insert-tech: clean, precise, synthetic. Almost subliminal — notice if missing, not if present. Needs trim to target duration (#453). | S7 #440 | | UI-001 | `cursor_hover.ogg` | done | UI Sounds | SAO | 77ms | Subtle tick/ping on entity hover (D-056). Insert-tech: clean, precise, synthetic. Trimmed + normalized to 44.1kHz in S9 #453. | S7 #440, S9 #453 |
| UI-002 | `implant_open.ogg` | draft | UI Sounds | SAO | 150-250ms | Rising tone when insert UI opens (radial menu, inventory, stance). Neural lattice powering up. Frequency sweep, harmonic shimmer. Needs trim (#453). | S7 #440 | | UI-002 | `implant_open.ogg` | done | UI Sounds | SAO | 217ms | Rising tone when insert UI opens (radial menu, inventory, stance). Neural lattice powering up. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-003 | `fog_recognition.ogg` | draft | UI Sounds | SAO | 300-400ms | Warm organic chime at onset of cognitive delay (D-060). Character recognizing a person. Unresolved tone. Needs trim (#453). | S7 #440 | | UI-003 | `fog_recognition.ogg` | done | UI Sounds | SAO | 400ms | Warm organic chime at onset of cognitive delay (D-060). Re-generated in S9 — original was silent. Organic prefix + Chimes template. | S7 #440, S9 #453 |
| UI-004 | `weapon_aim.ogg` | draft | UI Sounds | SAO | 100-150ms | Harder click/lock for weapon aim state. Mechanical, deliberate. SKETCH ONLY — no weapons in Sprint 7. Needs trim (#453). | S7 #440 | | UI-004 | `weapon_aim.ogg` | done | UI Sounds | SAO | 127ms | Harder click/lock for weapon aim state. Mechanical, deliberate. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | draft | UI Sounds | SAO | 500-1000ms | PLACEHOLDER. Soft crystalline tone, neural lattice surfacing a thought. Quiet, non-intrusive. Redo in next audio sprint (#453). See design brief below. | S7 #440 | | UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template. Crystalline tone, neural lattice surfacing a thought. See design brief below. | S7 #440, S9 #453 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | draft | UI Sounds | SAO | 500-1000ms | PLACEHOLDER. Sharper variant for contradiction/anomaly. Brighter, more insistent. Redo in next audio sprint (#453). See design brief below. | S7 #440 | | UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template (sharper/brighter variant). CFG 8. See design brief below. | S7 #440, S9 #453 |
| UI-007 | `sfx_weapon_aim_lock.ogg` | done | UI Sounds | SAO | 505ms | Sharp targeting lock-on confirmation for weapon aim. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
| UI-008 | `sfx_stance_change.ogg` | done | UI Sounds | SAO | 349ms | Soft mechanical mode-switch click for stance toggle. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
## Mix Specs (not audio files) ## Mix Specs (not audio files)
@@ -46,9 +48,26 @@ These are AudioBus parameter changes, documented in `docs/audio/dialogue-ambient
- **Integration:** Cognitive delay onset event → AudioManager.play_ui("fog_recognition") - **Integration:** Cognitive delay onset event → AudioManager.play_ui("fog_recognition")
- **Cross-reference:** D-060 (cognitive delay), D-018 (three-range sound, medium range) - **Cross-reference:** D-060 (cognitive delay), D-018 (three-range sound, medium range)
### UI-005 / UI-006: Monologue Chime Design Brief (PLACEHOLDER) ### UI-007: sfx_weapon_aim_lock
- **Sonic family:** insert-tech
- **Frequency range:** 1-3kHz primary
- **Design principle:** "Crosshair snapping to bone." Clinical confirmation that the weapon system has acquired a target. Harder and more aggressive than cursor_entity_lock — this is the weapon variant. Still UI feedback, not a combat sound. Should not startle.
- **Generation:** SAO 5s generation, trimmed to best 0.5s transient. Insert-tech prefix + UI template. CFG 8, 100 steps.
- **Integration:** cursor_state_machine.gd weapon_aim_lock signal → AudioManager.play_ui("sfx_weapon_aim_lock")
- **Cross-reference:** D-056 (cursor states), weapon_aim.ogg (aim enter, different from lock)
**Target emotional register for Sprint 8 redo:** ### UI-008: sfx_stance_change
- **Sonic family:** insert-tech
- **Frequency range:** 1-2kHz primary
- **Design principle:** "A precision dial clicking one notch." Subtle neural insert feedback on stance toggle. Player may toggle stances many times while positioning — must be short, pleasant, fatigue-safe. No long tails, no reverb. Nearly subliminal.
- **Generation:** SAO 5s generation, trimmed to best 0.35s transient. Insert-tech prefix + UI template. CFG 8, 100 steps.
- **Debouncing:** AudioManager should enforce 150ms cooldown (rapid stance toggling).
- **Integration:** stance_indicator.gd stance_changed signal → AudioManager.play_ui("sfx_stance_change")
- **Cross-reference:** D-048 (neural insert feedback aesthetic), D-053 (movement stances)
### UI-005 / UI-006: Monologue Chime Design Brief
**Target emotional register (achieved Sprint 9 #453):**
The monologue chimes are the two poles of augmented cognition made audible: The monologue chimes are the two poles of augmented cognition made audible:
@@ -65,9 +84,9 @@ The monologue chimes are the two poles of augmented cognition made audible:
**Quality bar:** If the urgent chime feels like a quest marker ping, it has failed. If it feels like Ubisoft, it has failed. The delta between normal and urgent = "someone thinking quietly" vs "someone's eyes going slightly wide." **Quality bar:** If the urgent chime feels like a quest marker ping, it has failed. If it feels like Ubisoft, it has failed. The delta between normal and urgent = "someone thinking quietly" vs "someone's eyes going slightly wide."
**Placeholder spec (Sprint 7):** **Sprint 9 generation spec:**
- Normal: pure sine 1.2kHz, 600ms, gentle attack, natural decay. Conservative volume. - Normal: SAO 5s generation (insert-tech prefix + chimes template), CFG 7, 100 steps. Trimmed to 0.8s (attack + initial decay). LUFS normalized to -16.
- Urgent: sine 1.2kHz + harmonic 2.4kHz, 400ms, sharper attack, 15% louder. - Urgent: SAO 5s generation (insert-tech prefix + chimes template, sharper/brighter descriptors), CFG 8, 100 steps. Trimmed to 0.8s. Stronger attack (-6.3dB peak vs -15.3dB normal), more sustained energy.
- Deliberately undersized — obviously temporary. Ugly placeholders get replaced. - Both at 44.1kHz stereo, Vorbis q6 encoding.
**Evaluation gate:** First internal playtest after Sprint 7: "Does the normal chime feel like a thought arriving? Does the urgent chime create a physiological response?" If no → trigger chime redesign. **Evaluation gate:** First internal playtest: "Does the normal chime feel like a thought arriving? Does the urgent chime create a physiological response?" If no → iterate with manual synthesis.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
# Sprint 9: Gauntlet — Audio Tasks
**Goal:** Build the test infrastructure backbone — test client binary, Gauntlet rooms 1-3, golden file suite, and client QA harness — so every future sprint can be validated end-to-end.
**Branch:** `audio`
**Agents:** Inigo (sound design)
## Carry-over from Sprint 8
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #440 | 6 interaction audio assets — cursor hover, weapon mode, fog recognition, implant, dialogue, confrontation dip | in_progress | In progress with Inigo. Highest priority — complete before starting #453. |
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #453 | Revisit Sprint 7 UI audio assets — trim to target durations and iterate | — (after #440) |
## Key Decisions
- `decisions/architecture.md` — D-068 (5-bus audio architecture: Music, Ambient, WorldSFX, PlayerActions, UISounds; AudioManager directory-scan registry)
- `decisions/architecture.md` — D-073 (zone crossfade: hard tile boundary, 1.5-2s audio tween; `AudioManager.set_zone()` stub exists in `client/scripts/autoloads/audio_manager.gd`)
- `decisions/scope.md` — D-038 (hybrid audio generation: SAO for >200ms organic sounds, manual synthesis for <200ms precise/digital UI sounds; insert-tech vs organic split from D-074)
- `decisions/perception.md` — D-069 (audio dip profiles: dialogue -7dB Ambient; confrontation -11dB Ambient + -5dB WorldSFX + 800Hz low-pass; dip tweens already implemented in AudioManager)
## Notes
### #440 — 6 interaction audio assets
What exists: `client/scripts/autoloads/audio_manager.gd` is fully implemented (D-068, D-069, D-073). It uses a directory-scan registry: drop an `.ogg`, `.wav`, or `.mp3` file into `res://audio/` and it auto-registers by filename stem. No code changes needed to add assets — audio branch can add files independently of client code.
The 5-bus routing is live: `UISounds` bus handles cursor hover and interaction feedback; `Ambient` and `WorldSFX` buses are handled by the dialogue/confrontation dip profiles. `apply_dip("confrontation")` and `apply_dip("dialogue")` are implemented.
What to deliver: 6 audio files committed to the `audio` branch under `res://audio/`:
1. `sfx_cursor_hover.ogg` — subtle UI feedback for cursor landing on an interactable entity. Short (<200ms), precise. Manual synthesis (insert-tech aesthetic per D-074).
2. `sfx_weapon_mode.ogg` — stance shift or mode toggle cue. Digital, clean. Manual synthesis.
3. `sfx_fog_recognition.ogg` — "neural ping" as a previously-unseen entity resolves from grey blob to full color (D-060 cognitive delay completion). Crystalline, directional feel. SAO or manual synthesis.
4. `sfx_implant_open.ogg` — insert/HUD open event. Diegetic tech sound. SAO for organic texture if >200ms; manual for shorter.
5. `sfx_dialogue_start.ogg` — conversation initiates. Subtle, not jarring — dialogue already has the -7dB Ambient dip cue. Avoid competing with voice/text readability.
6. `sfx_confrontation_dip.ogg` — confrontation audio dip accompaniment. This fires when `apply_dip("confrontation")` is called. Should feel like the world narrows — compressed, focused. Plays on UISounds bus.
Gotcha: The AudioManager calls `play()` with the asset filename stem as the key. Asset key must match exactly — e.g., file `sfx_cursor_hover.ogg` is called with `AudioManager.play("sfx_cursor_hover")`. Verify stems before committing.
Duration targets: all 6 are UI sounds (UISounds bus) — keep under 1.0s. Dialogue and confrontation sounds should be especially short to avoid competing with text display timing.
### #453 — Revisit Sprint 7 UI audio assets — trim to target durations and iterate
What exists: Sprint 7 UI audio assets exist in the audio branch under `res://audio/`. They were produced as initial placeholders. Team feedback (Tyre, Gestalt) flagged that some assets exceed target durations or don't fit the insert-tech vs organic aesthetic split from D-074.
What to deliver: Review each Sprint 7 UI asset against the D-038 / D-074 standards:
- `sfx_monologue_chime.ogg` — target 0.5-1.0s, crystalline/neural-lattice feel. Re-synthesize if duration or tone is off.
- `sfx_monologue_chime_urgent.ogg` — sharper variant, same duration range. Must feel distinctly more urgent than the base chime.
- Any other Sprint 7 UI assets — check stems against AudioManager registry, confirm routing to correct bus.
Output: revised assets committed to `audio` branch, replacing the originals. Note which files changed and why in the PR description. If an asset is acceptable as-is, note that too.
## Dependency Chain
```
#440 (6 interaction audio assets) — in progress, complete first
-> #453 (Sprint 7 asset revision, start after #440 is done)
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(audio): sprint 9 gauntlet — interaction SFX pack and Sprint 7 asset revisions" --description "body" --base main --head audio
```
+131
View File
@@ -0,0 +1,131 @@
# Sprint 9: Gauntlet — CI Tasks
**Goal:** Build the test infrastructure backbone — test client binary, Gauntlet rooms 1-3, golden file suite, and client QA harness — so every future sprint can be validated end-to-end.
**Branch:** `ci`
**Agents:** Justine (build/deploy)
## Carry-over from Sprint 8
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #460 | make pre-pr target | backlog | Critical — did not start in Sprint 8. Linchpin for the pre-PR validation chain. Blocks #465. |
| #464 | Content cross-reference validation (9 checks) | backlog | Critical — extends `tooling/validate-content`. Standalone from #460. |
| #465 | Fixture staleness check in make pre-pr | backlog | Blocked by #460. |
| #475 | make fixtures-client target (GDScript-generated fixtures for Rust) | backlog | #474 completed Sprint 8 — now unblocked. |
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #475 | make fixtures-client target (GDScript-generated fixtures for Rust) | — (#474 done Sprint 8) |
| #486 | make golden-diff + make golden-update targets | #485 (server) |
| #497 | Checklist YAML schema + make checklist generation | #487 (server) |
## Key Decisions
- `decisions/architecture.md` — D-030 (testability architecture: pre-PR chain, fixture staleness, Layer 3 integration tests; total runtime <3min for pre-PR target)
- `decisions/architecture.md` — D-020 (server binary is a standalone Rust binary; `make pre-pr` must build it as part of the chain)
## Notes
### #460 — make pre-pr target
What exists: The top-level `Makefile` has `validate-content` (wraps `tooling/validate-content`) and individual `ci-server`, `ci-client` targets (see `docs/DEVOPS.md`). The `make pre-pr` target does not yet exist.
What to deliver: `make pre-pr` in the top-level `Makefile` as an ordered chain:
1. `lint-server` (Clippy)
2. `build-server` (cargo build)
3. `test-server` (cargo nextest)
4. `lint-client` (GDScript linter if any)
5. `build-client`
6. `test-client` (gdUnit4 headless)
7. `validate-content` (schema validation)
8. Fixture staleness check (stub hook for #465)
Total runtime target: <3 minutes on a clean build. Branch-specific variants: `make pre-pr-server` (steps 1-3 only), `make pre-pr-client` (steps 4-6 only), `make pre-pr-content` (step 7 only). These let individual teams run their own slice.
Gotcha: Server binary must be built before Layer 3 tests (which spawn it as a subprocess per D-030). Ensure `build-server` precedes `test-server` in the chain. Use `$(MAKE)` for sub-targets to get proper error propagation.
### #464 — Content cross-reference validation (9 checks)
What exists: `tooling/validate-content` is an executable script that runs YAML schema validation. Extend it with a cross-reference pass.
What to deliver: 9 cross-reference checks in `tooling/validate-content` (or a companion script it calls):
1. `canonical_id` uniqueness — no two content items share an ID
2. Relationship target resolution — every `target_id` in a relationship block resolves to a known entity
3. Location slug resolution — every `location_slug` references a known location
4. Dialogue location resolution — every dialogue `location` field resolves
5. Fact tag validity — every fact tag is in the registered tag vocabulary
6. Line pool tag coverage — every monologue line's trigger tags exist in content
7. Archetype consistency — every NPC archetype value is a known enum variant
8. Routine phase validity — every routine `phase` field is a valid DayPhase value
9. (Check 9 is Bidirectional relationship consistency — advisory warning, non-blocking, see #515 for full impl; stub it here as a TODO comment)
Exit 0 if all checks pass (warnings OK for check 9). Exit 1 on any blocking failure. Output: per-check pass/fail with file+line reference for failures.
### #465 — Fixture staleness check in make pre-pr
What exists: `server/tests/gen_fixtures.rs` generates `.msgpack` files at `client/tests/fixtures/msgpack/`. The fixtures committed to git are the source of truth. If code changes alter fixture output without regenerating, client tests will fail with misleading errors.
What to deliver: A script (or Makefile target `check-fixture-staleness`) that:
1. Runs `cargo test gen_fixtures` to regenerate fixtures into a temp location
2. `git diff` the output against `client/tests/fixtures/msgpack/`
3. Exit 1 if any fixture differs (stale fixtures = false positive in client tests)
4. Print a clear message: "Fixtures stale — run `make fixtures-regen` to update"
Integrate as step 8 in `make pre-pr` (blocked by #460 providing the chain structure). Also expose as a standalone `make check-fixtures` target.
### #475 — make fixtures-client target (GDScript-generated fixtures for Rust)
What exists: #474 was completed in Sprint 8 — GDScript encoding asymmetry tests are done and validated. This target is now unblocked. It exercises the reverse direction: GDScript encoder → Rust decoder.
What to deliver: `make fixtures-client` Makefile target that:
1. Runs the Godot headless GDScript test that generates `.msgpack` files at `server/tests/fixtures/gdscript/`
2. The Rust integration test in `server/tests/serialization.rs` (or a new file) then verifies it can decode those files
This validates the full bidirectional protocol: Rust encodes → GDScript decodes (#474), GDScript encodes → Rust decodes (#475). Together they close the cross-encoder compatibility loop.
### #486 — make golden-diff + make golden-update targets
What exists: Blocked by #485 (server golden file suite). The golden file lives at `tests/golden/gauntlet_tick_10.json` (created by #485).
What to deliver: Two Makefile targets:
- `make golden-diff`: runs the golden file test, shows a diff if the output has changed (use `diff --color` or `jq` for pretty output). Exits 1 if changed.
- `make golden-update`: regenerates `tests/golden/gauntlet_tick_10.json` from a fresh run and stages it with `git add`. Does NOT commit — leaves the diff for developer review.
These are developer workflow targets, not CI gates. Document in `make` help output.
### #497 — Checklist YAML schema + make checklist generation
What exists: Blocked by #487 (server Gauntlet rooms — provides room IDs for checklist scope). Checklists live at `content/gauntlet/rooms/{room_id}/checklist.yaml` (per-room) and `content/gauntlet/cross_room_checks.yaml`.
What to deliver:
- YAML schema definition (JSON Schema or serde-compatible struct): 7 condition types: `player_near`, `player_facing`, `entity_present`, `entity_absent`, `expected_monologue`, `expected_dialogue`, `expected_interaction_verb`. Each condition has `id`, `description`, `condition_type`, and type-specific fields.
- `make checklist-generate` Makefile target: validates all checklist YAML against the schema, outputs a summary of condition counts per room
- `make checklist-validate` target: runs the schema check standalone (useful in pre-PR chain)
The checklist format feeds into #503 (client auto-checklist progress tracking) in Sprint 10. Design the schema with that consumer in mind — conditions should be evaluable from an `ObserverSnapshot`.
## Dependency Chain
```
#460 (make pre-pr) — start immediately, no blockers
-> #465 (fixture staleness check)
#464 (content cross-reference validation) — standalone, parallel to #460
#474 (done Sprint 8) -> #475 (make fixtures-client, unblocked)
#485 (server, golden suite) -> #486 (make golden-diff + golden-update)
#487 (server, Gauntlet rooms) -> #497 (checklist YAML schema)
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): sprint 9 gauntlet — pre-pr chain, content validation, golden targets" --description "body" --base main --head ci
```
+112
View File
@@ -0,0 +1,112 @@
# Sprint 9: Gauntlet — Client Tasks
**Goal:** Build the test infrastructure backbone — test client binary, Gauntlet rooms 1-3, golden file suite, and client QA harness — so every future sprint can be validated end-to-end.
**Branch:** `client`
**Agents:** Stig (client dev), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #492 | Client P2 tests: remaining camera (5), entity alpha/color (4), UI (7) | — |
| #493 | Client P3 tests: z-layer ordering (4), entity lerp (3), Tyre additions (5) | — |
| #494 | Client anti-tedium tests: bug report capture, progress hidden when not gauntlet | — |
| #495 | WRONG button (F12) MVP: snapshot + text render + description | #481 (server), #490 (server) |
| #496 | Room timer + personal bests | #487 (server) |
| #516 | Fix MessagePack int_64 encoder dead code branch | — |
## Key Decisions
- `decisions/architecture.md` — D-020 (ObserverSnapshot protocol, client is pure renderer), D-030 (gdUnit4 test framework, JSON output, headless stable)
- `decisions/architecture.md` — D-068 (5-bus AudioManager — audio_manager.gd exists), D-066 (dual-scale grid: TILE_SIZE in constants.gd maps 0.5m sim tiles)
- `decisions/scope.md` — D-053 (movement stances — entity lerp speed tuned per stance in entity_renderer.gd)
## Notes
### #492 — Client P2 tests: remaining camera (5), entity alpha/color (4), UI (7)
What exists: `client/scripts/rendering/entity_renderer.gd` handles entity alpha dimming (Peripheral vision = `Constants.PERIPHERAL_ALPHA`), entity color by kind via `Constants.color_for_entity_kind()`. Camera logic is in `client/scripts/` — check for a camera or follow node in the main scene. Existing tests at `client/tests/test_rendering.gd` and `client/tests/test_camera_anchor.gd` provide patterns.
What to deliver: 16 new gdUnit4 tests split across categories:
- Camera (5): smoothing convergence, zoom levels, viewport clamping, follow target tracking, no-panning constraint (D-014)
- Entity alpha/color (4): `Peripheral` -> `PERIPHERAL_ALPHA`, `Forward` -> alpha 1.0, NPC color by relationship state, Player color constant
- UI (7): monologue display visible/hidden, interaction panel shows nearest verb, inventory HUD shows carried items, dialogue overlay appears on `current_dialogue` set, pause indicator on `TickRate::Paused`, recognition blob rendered for `pending_recognitions`, fog overlay covers occluded tiles
Use `GdUnitSceneRunner` for scene lifecycle tests. Follow patterns in `client/tests/test_p0_regressions.gd` and `client/tests/test_client_p1.gd`.
### #493 — Client P3 tests: z-layer ordering (4), entity lerp (3), Tyre additions (5)
What exists: `entity_renderer.gd` implements framerate-independent lerp with `LERP_SPEED = 12.0` (exponential smoothing). `_entity_targets` Dictionary tracks target pixel positions. `tile_renderer.gd` and `world_renderer.gd` handle tile layers.
What to deliver: 12 new gdUnit4 tests:
- Z-layer ordering (4): floors render below walls, walls below entities, entities below UI, verify draw order via node tree position or `z_index`
- Entity lerp (3): entity node position starts at spawn point (no lerp on first appear), position moves toward `_entity_targets` over time (mock delta), position converges within ~0.3s of real time at `LERP_SPEED = 12.0`
- Tyre additions (5): recognition transition timing (grey blob -> colored entity over `total_delay_ticks`), facing indicator rotation matches `player_facing` from snapshot, sprint snappiness (lerp arrives faster at higher lerp weight), plus two further items from Tyre's round3 notes — check `db/connectors/ticket show 493` for full list
### #494 — Client anti-tedium tests: bug report capture, progress hidden when not gauntlet
What exists: Sprint 9 introduces the WRONG button F12 MVP (#495) and room timer (#496). These features have "anti-tedium" guards — the progress/timer UI must not appear in non-Gauntlet mode.
What to deliver: 2 regression tests:
- F12 bug report capture: when F12 is pressed, client pauses, capture dialog appears, saved files are produced (mock the file system or check paths are attempted)
- Gauntlet progress UI: in non-Gauntlet mode (no `gauntlet_mode` flag or room metadata), the room timer and personal-bests overlay must not be visible
These are client-side regression tests. Add to `client/tests/test_p0_regressions.gd` or a new `test_anti_tedium.gd`.
### #495 — WRONG button (F12) MVP: snapshot + text render + description
What exists: Blocked by #481 (server text renderer, provides `format_snapshot_text()`) and #490 (server room reset — context for room metadata in capture). `game_state.gd` holds `current_snapshot` (full Dictionary), `current_tick`, `player_stance`. The `sim_bridge.gd` handles IPC. Audio is in `audio_manager.gd`.
What to deliver: F12 key binding in `client/scripts/autoloads/input_mapper.gd` (or main scene). On press:
1. Send `PlayerAction::Pause` via sim_bridge
2. Show a one-line prompt: "Describe the issue:" (simple LineEdit dialog)
3. On confirm: save three files to `tests/bug-reports/YYYY-MM-DD_HH-MM-SS/`:
- `snapshot.json` — `current_snapshot` as JSON
- `render.txt` — text render output from server's `format_snapshot_text()` (request via a special IPC call, or reproduce in GDScript using snapshot data)
- `description.txt` — tester's typed description + tick/room/seed metadata
4. Send `PlayerAction::Unpause`
Gotcha: `format_snapshot_text()` is a server-side Rust function (#481). For MVP, reproduce a simplified version client-side in GDScript using `current_snapshot` data, so this ticket is not blocked at runtime even if the test client binary isn't connected. Full fidelity (exact server text output) is a stretch goal.
### #496 — Room timer + personal bests
What exists: Blocked by #487 (Gauntlet rooms — provides room_id per snapshot). `game_state.gd` tracks `current_tick`. The `ObserverSnapshot` does not currently include `room_id` — this needs a server-side field or derivation from entity positions.
What to deliver: Client-side GDScript in the main scene or a new `gauntlet_hud.gd`:
- Timer label: `TIMER: MM:SS` displayed in corner
- Timer starts on room entry event (snapshot indicates new room), resets on room reset trigger
- Personal bests loaded from / saved to `tests/gauntlet-stats.json` (FileAccess)
- Session summary printed on disconnect
- UI hidden entirely in non-Gauntlet mode
Coordinate with server team: `ObserverSnapshot` needs a `room_id: Option<String>` field or equivalent (may be added as part of #487/#488 server work). Use `#[serde(default)]` on the server side so the client can handle both old and new snapshots.
### #516 — Fix MessagePack int_64 encoder dead code branch
What exists: The GDScript MessagePack encoder has a dead code branch for `int_64` encoding. Identified as a bug in Sprint 8 backlog scan. Location: likely in the MessagePack encode/decode utility in `client/scripts/protocol/` — check for the file handling integer encoding.
What to deliver: Fix the dead code branch, add a test case for the int_64 path, verify it encodes and decodes correctly. Small ticket (~0.5d). Do this early in the sprint as a warm-up before the larger test suites.
## Dependency Chain
```
#516 (msgpack fix) — standalone, start early
#492 (P2 tests) — standalone, parallel
#493 (P3 tests) — standalone, parallel
#494 (anti-tedium tests) — standalone (can stub #495 behavior)
#495 (WRONG button F12) — blocked by #481 + #490 (server)
-> (unblocks #507 in Sprint 10)
#496 (room timer) — blocked by #487 (server)
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 9 gauntlet — QA harness and F12 WRONG button" --description "body" --base main --head client
```
+147
View File
@@ -0,0 +1,147 @@
# Sprint 9: Gauntlet — Joint / Integration
**Goal:** Build the test infrastructure backbone — test client binary, Gauntlet rooms 1-3, golden file suite, and client QA harness — so every future sprint can be validated end-to-end.
**Agents:** All implementation agents (Dudley, Stig, Hoshe, Tyre, Justine, Inigo)
---
## Pre-Sprint Decisions
No new decisions are required before Sprint 9 begins. All sprint tickets operate within confirmed decisions. Items to monitor:
| Item | Status | Needed by |
|------|--------|-----------|
| `room_id` field in ObserverSnapshot | Server team must add as `Option<String>` with `#[serde(default)]` (part of #487/#488 scope) | #496 (room timer, client) |
| Checklist YAML schema design | CI and server team to align on condition types before #497 starts | #497 (checklist YAML, CI) |
| `format_snapshot_text()` signature | Server team (#481) must export pub from server crate before client can use it | #495 (WRONG button, client) |
---
## Cross-Team Dependencies
```
Server #480 (test client binary)
-> Server #481 (text renderer, pub export)
-> Client #495 (WRONG button F12 capture)
-> Server #484 (golden diff engine)
-> Server #485 (golden suite)
-> CI #486 (make golden-diff + golden-update)
Server #487 (Gauntlet rooms 1-3)
-> Server #488 (room constants)
-> Server #490 (room reset trigger)
-> Client #495 (WRONG button — reset plate context)
-> Client #502 (room reset UX — Sprint 10)
-> Client #496 (room timer)
-> CI #497 (checklist YAML schema)
Client #474 (done Sprint 8)
-> CI #475 (make fixtures-client, unblocked at sprint start)
CI #460 (make pre-pr)
-> CI #465 (fixture staleness check)
```
**Critical path:** `#480 → #481 → #484 → #485 → #486`. This chain must start and progress in the first days of the sprint. Tyre should review #480's workspace crate structure before Dudley starts to avoid rework.
---
## Sprint Completion Proof
Sprint 9 is complete when all of the following are observable:
**Test client and golden suite**
- `tooling/test-client/` builds cleanly as a separate workspace crate (`cargo build -p test-client`)
- Running `./tooling/test-client --connect <port> --json` against a live server produces formatted JSON output with tick, entities, and stance data
- `make golden-diff` exits 0 (snapshot output matches committed golden file)
- `make golden-update` regenerates `tests/golden/gauntlet_tick_10.json` without error
**Layer 3 subprocess test**
- `cargo nextest run layer3` passes: server spawns, `LISTENING:{port}` received, connection established, snapshot deserialized, all assertions green
**Gauntlet rooms**
- Server boots with Gauntlet content and three rooms load without panic
- `room_at(x, y)` resolves a coordinate within each of the 3 room boundaries to the correct `GauntletRoom`
- Room reset on trigger: entity positions, player KG (room refs), and fog return to tick-0 state
**Client QA**
- All P2 tests (#492), P3 tests (#493), and anti-tedium tests (#494) pass headless in `make test-client`
- F12 keypress in Godot (Gauntlet mode) pauses simulation, prompts for description, and produces 3 files in `tests/bug-reports/`
- Room timer displays in Gauntlet mode, hidden in non-Gauntlet mode
**CI chain**
- `make pre-pr` exits 0 on a clean checkout (lint + build + test + content validate + fixture staleness)
- `make pre-pr-server` and `make pre-pr-client` exist as branch-specific variants
- Content cross-reference validation (`tooling/validate-content`) runs all 9 checks without false positives on current content
**Audio**
- 6 interaction SFX assets committed to `audio` branch under `res://audio/`
- AudioManager registers them at startup (no-op fallback if absent — client branch unchanged)
- Sprint 7 chime assets revised and re-committed
---
## Test Plan Alignment (D-030)
Sprint 9 is Phase 3+ of the D-030 testability phases. The sprint adds the Layer 3 real subprocess test and the golden file regression suite. Both are explicit D-030 goals:
| D-030 Phase | Target | Sprint 9 Status |
|-------------|--------|-----------------|
| Phase 1 (Sprints 1-2): test infra | Done (Sprints 1-2) | |
| Phase 2 (Sprints 3-4): monologue pipeline, info boundary negatives | Done (Sprints 3-5) | |
| Phase 3 (Sprint 5+): CauseChain verification, divergent snapshots | Gauntlet golden suite (#485) starts this | In scope |
| Layer 3: real subprocess | `server/tests/layer3.rs` (#482) | In scope |
| Pre-PR gate: <3min full chain | `make pre-pr` (#460) | In scope |
| Fixture staleness detection | `check-fixture-staleness` (#465) | In scope |
| Cross-encoder protocol validation | #474 + #475 | In scope |
---
## Open Questions to Resolve Early
No blocking Q-NNN items for Sprint 9 tickets. Monitor:
- **`room_id` in snapshot**: Server team should confirm whether `ObserverSnapshot` gets a `room_id: Option<String>` field in this sprint (scope of #487/#488) or whether the client derives room from entity positions. Client #496 needs this resolved by mid-sprint.
- **Test client crate location**: Confirm `tooling/test-client/` as the workspace crate path with Tyre before #480 starts. The `tooling/` directory is currently scripts only — a Cargo workspace member there is new.
---
## Sequence Recommendation
**Days 1-3 (all teams in parallel):**
- Server: start #480 immediately (no blockers). Tyre reviews workspace structure.
- Server: start #487 in parallel (no blockers). Dudley on rooms, Tyre on architecture review.
- Server: start #489 (standalone, no blockers).
- Client: start #516 (small warm-up), then #474 (fixtures ready).
- Client: start #492, #493, #494 in parallel (all standalone).
- CI: start #460 and #464 in parallel (both standalone).
- Audio: finish #440 (in progress).
**Days 4-6:**
- Server: #481, #482 unblock once #480 is done.
- Server: #488, #490 unblock once #487 is done.
- CI: #465 unblocks once #460 is done. #475 unblocks once #474 is done.
- Client: #496 unblocks once #487 server work is done.
- Audio: start #453 once #440 is complete.
**Days 7-10:**
- Server: #484, #485 unblock once #481 is done.
- CI: #486 unblocks once #485 is done. #497 unblocks once #487 is done.
- Client: #495 unblocks once #481 + #490 are done.
---
## PR Workflow
Each team submits their own PR from their branch. Integration is via merge to `main`. No joint branch — all tickets are single-team (see team assignments in each briefing). The `joint.md` tracks integration readiness but does not produce a separate branch.
```bash
# Verify sprint assignment before PR
db/connectors/sprint status --team <team>
# Standard PR (all flags required — see CLAUDE.md)
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(<scope>): sprint 9 gauntlet — <team summary>" \
--description "body" --base main --head <branch>
```
+130
View File
@@ -0,0 +1,130 @@
# Sprint 9: Gauntlet — Server Tasks
**Goal:** Build the test infrastructure backbone — test client binary, Gauntlet rooms 1-3, golden file suite, and client QA harness — so every future sprint can be validated end-to-end.
**Branch:** `server`
**Agents:** Dudley (simulation dev), Tyre (architect), Hoshe (QA)
## Carry-over from Sprint 8
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #480 | Test client binary scaffolding + CLI | backlog | Linchpin — blocks #481, #482, #483, #484. Was Sprint 8 scope but did not start. |
| #481 | Text renderer library | backlog | Blocked by #480. Needed for golden diff and WRONG button (F12). |
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #482 | Layer 3 subprocess test: server_subprocess_sends_snapshot_on_connect | #480 |
| #484 | Test client golden file comparison (JSON diff) | #480, #481 |
| #485 | Golden file test suite: gauntlet_tick_10_matches_golden | #484 |
| #487 | Gauntlet first 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber) | — |
| #488 | Gauntlet room constants module (server/src/test_world/constants.rs) | #487 |
| #489 | Content runtime validation: boot + tick 10 + snapshot | — |
| #490 | Room reset trigger mechanism | #487 |
## Key Decisions
- `decisions/architecture.md` — D-020 (subprocess IPC, `ObserverSnapshot`, `PlayerInput`), D-030 (testability architecture, Layer 3 integration tests), D-010 (deterministic simulation, injectable resources)
- `decisions/scope.md` — D-065 (smuggler inventory, `CarriedBy` component — Inventory Warehouse room), D-053 (movement stances — Pause Chamber room)
- `decisions/perception.md` — D-035 (symmetric shadowcasting — Occlusion Corridor room)
## Notes
### #480 — Test client binary scaffolding + CLI (tooling/test-client/)
What exists: `server/` is a workspace crate (see `server/Cargo.toml`). The `bridge` module exports `ObserverSnapshot`, `PlayerInput`, `read_framed`, `write_framed` from `server/src/bridge/`. `TcpBridge` is in `server/src/bridge/tcp.rs`. The server binary (`server/src/main.rs`) already implements `--test-mode --port 0` and emits `LISTENING:{port}` on stdout (Sprint 8, #459).
What to deliver: A new Cargo workspace crate at `tooling/test-client/` that imports bridge types from the server crate. CLI flags: `--connect <port>`, `--replay <file>`, `--text` (text output), `--json` (JSON output), `--quiet`. This is the foundation for every subsequent test client ticket in this sprint — do it first.
Integration point: The server crate must expose `read_framed` and `write_framed` as `pub` from `server/src/bridge/framing.rs` (verify they are already public; the integration test at `server/tests/game_loop.rs` already uses them).
### #481 — Text renderer library (server/src/bridge/text_renderer.rs)
What exists: `ObserverSnapshot` is fully defined in `server/src/bridge/types.rs` (v8, protocol version 8). Fields include `entities` (Vec<VisibleEntity>), `visible_tiles`, `tick`, `game_time`, `player_stance`, `player_inventory`, etc.
What to deliver: `format_snapshot_text(&ObserverSnapshot) -> String`. Entity labels as `kind:entity_id`, sorted by distance from player position. Room name from `room_at()` helper (or "unknown" stub if room system not yet in place). Export as `pub` from the server crate for `tooling/test-client/` consumption. This is also consumed by #495 (WRONG button F12 capture on the client).
### #482 — Layer 3 subprocess test: server_subprocess_sends_snapshot_on_connect
What exists: `server/tests/game_loop.rs` already has a full IPC round-trip test using `TcpBridge::accept_on()`. The `--test-mode --port 0` + `LISTENING:{port}` pattern is live in `server/src/main.rs`. The test for #459 already validated the handshake.
What to deliver: A new test at `server/tests/layer3.rs`. Spawns the server binary with `--test-mode --port 0`, reads `LISTENING:{port}` from stdout, connects via TCP (`TcpBridge::connect()`), sends one `PlayerInput`, reads one `ObserverSnapshot`, asserts `version == PROTOCOL_VERSION`, `tick >= 0`, `entities.len() > 0`. This is D-030 Layer 3 — the real subprocess, not a mock.
Gotcha: The server binary must be pre-built before this test runs. Use `std::process::Command` with the binary path from `env!("CARGO_MANIFEST_DIR")` or a build script. Look at how `server/tests/bridge_tcp.rs` handles spawning if there is a prior pattern.
### #484 — Test client golden file comparison (JSON diff)
What exists: #480 (test client crate) must be done first. The `ObserverSnapshot` struct derives `Serialize`/`Deserialize` via serde.
What to deliver: A recursive `diff_json_values()` comparison function in `tooling/test-client/src/golden.rs`. Sorts object keys before compare (BTreeMap semantics). Pretty-prints field-level diffs on mismatch (path + expected + got). Exit code 1 on any mismatch. This is the comparison engine for #485.
### #485 — Golden file test suite: gauntlet_tick_10_matches_golden
What exists: #484 must be done. The determinism regression test at `server/tests/determinism.rs` (Sprint 8, #466) already validates tick-level determinism. Gauntlet room #487 must be done (provides the map for the replay).
What to deliver: A test in `server/tests/` (or via the test client) that runs a 10-tick deterministic replay against the Gauntlet map, serializes the resulting `ObserverSnapshot` to JSON, and compares against a committed golden file at `tests/golden/gauntlet_tick_10.json`. First run: generate and commit the golden file. Subsequent runs: fail on any deviation. This is the primary regression guard.
### #487 — Gauntlet first 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber)
What exists: Content loading system is live — `server/src/content/` (ContentPlugin, ContentConfig, loader, spawn). The proof room setup in `server/src/main.rs` (`setup_proof_room()`) shows the ECS entity spawning pattern. `WalkabilityMap` is the tile collision resource. The room system needs a new `server/src/test_world/` module (Gauntlet-specific, not production content).
What to deliver: Three rooms as hybrid YAML + Rust inject:
- Inventory Warehouse (30x28, 11 entities): tests D-065 inventory system, `CarriedBy` component
- Occlusion Corridor (42x22, 4 entities): tests D-035 LOS shadowcasting, wall placement critical
- Pause Chamber (16x16, 1 entity): tests D-053 pause guard; `--test-mode` gate check
Room layout: embed in `server/src/test_world/rooms/` as YAML, with a Rust loader in `server/src/test_world/mod.rs` that spawns them via `WalkabilityMap` + ECS entities. Each room gets a unique `room_id` string. This module is Gauntlet-only — do not pollute `server/src/content/`.
Blocks: #488 (constants module), #490 (room reset), #497 (checklist YAML), #496 (room timer), #498 (next 3-4 rooms).
### #488 — Gauntlet room constants module (server/src/test_world/constants.rs)
What exists: #487 must be done first. Depends on room definitions from that ticket.
What to deliver: `GauntletRoom` struct (id, name, spawn_x, spawn_y, width, height), `GauntletEntity` struct (stable_id, kind, x, y), 8 room constants (one per planned Gauntlet room — stub the unimplemented ones), `ROOMS: &[GauntletRoom]` array, `room_at(x, y) -> Option<&GauntletRoom>` lookup. ~100 lines. These stable coordinate references are used by all Gauntlet tests to avoid magic numbers.
### #489 — Content runtime validation: boot + tick 10 + snapshot
What exists: `ContentPlugin` loads from `content/` at startup. If content schema is valid YAML but has runtime issues (e.g., NPC references broken entity IDs), the server may panic on tick rather than at load time.
What to deliver: A test at `server/tests/content_loading.rs` (or extend the existing file) that: starts the server with `ContentPlugin`, ticks 10 times, reads an `ObserverSnapshot`, asserts no panic and `version == PROTOCOL_VERSION`. This is a smoke test for production content. Standalone — does not depend on Gauntlet rooms.
### #490 — Room reset trigger mechanism
What exists: #487 (Gauntlet rooms) must be done. ECS has `WalkabilityMap`, `TilePosition`, `KnowledgeGraph` components.
What to deliver:
- `RoomResetTrigger` component: marks a tile as a reset plate
- `RoomSnapshots` resource: stores tick-0 entity state per room (positions, KG entries, inventory items)
- `execute_room_reset` system: on trigger activation, restores entity positions, player KG (room refs only), fog (room tiles), inventory items to tick-0 state
Resets: entity positions and KG, player KG (room refs only — not cross-room knowledge), fog (room tiles only), inventory items. Does NOT reset: game time, player stance, cross-room knowledge, the hub area.
Blocks: #495 (WRONG button F12 depends on reset plate context), #502 (client-side reset UX).
## Dependency Chain
```
#480 (test client binary)
-> #481 (text renderer)
-> #484 (golden diff)
-> #485 (golden suite)
-> #482 (Layer 3 subprocess test)
-> #484 (golden diff)
#487 (Gauntlet rooms 1-3)
-> #488 (room constants)
-> #490 (room reset trigger)
#489 (content runtime validation) — standalone, parallel track
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 9 gauntlet — test client and gauntlet rooms" --description "body" --base main --head server
```
+20
View File
@@ -950,6 +950,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.34+deprecated" version = "0.9.34+deprecated"
@@ -975,6 +988,7 @@ dependencies = [
"rand_chacha", "rand_chacha",
"rmp-serde", "rmp-serde",
"serde", "serde",
"serde_json",
"serde_yaml", "serde_yaml",
"thiserror", "thiserror",
"tracing", "tracing",
@@ -1373,3 +1387,9 @@ dependencies = [
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+7
View File
@@ -16,3 +16,10 @@ pathfinding = "4.11"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[features]
default = ["gauntlet"]
gauntlet = []
[dev-dependencies]
serde_json = "1"
+37
View File
@@ -69,6 +69,19 @@ impl EntityRegistry {
} }
} }
/// Advance the next StableId counter to `target`.
/// Used to reserve StableId ranges for entities not yet spawned
/// (e.g., Gauntlet rooms built in later sprints).
/// Panics if `target` is less than the current next_id.
pub fn reserve_up_to(&mut self, target: u64) {
assert!(
target >= self.next_id,
"cannot reserve backwards: next_id={}, target={}",
self.next_id, target
);
self.next_id = target;
}
/// Number of registered entities. /// Number of registered entities.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.by_entity.len() self.by_entity.len()
@@ -229,6 +242,30 @@ mod tests {
); );
} }
#[test]
fn reserve_up_to_advances_counter() {
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let e1 = world.spawn_empty().id();
let id1 = registry.register(e1);
assert_eq!(id1, StableId(0));
// Reserve through 5 (skip IDs 1-4)
registry.reserve_up_to(5);
let e2 = world.spawn_empty().id();
let id2 = registry.register(e2);
assert_eq!(id2, StableId(5), "next ID after reserve should be 5");
}
#[test]
#[should_panic(expected = "cannot reserve backwards")]
fn reserve_up_to_panics_on_backwards() {
let mut registry = EntityRegistry::new(10);
registry.reserve_up_to(5);
}
#[test] #[test]
fn unregister_unknown_entity_is_noop() { fn unregister_unknown_entity_is_noop() {
// #469: Unregistering an entity that was never registered must not panic. // #469: Unregistering an entity that was never registered must not panic.
+4
View File
@@ -9,3 +9,7 @@ pub mod npc;
pub mod perception; pub mod perception;
pub mod simulation; pub mod simulation;
pub mod storyteller; pub mod storyteller;
// test_world::reset is always compiled (used by simulation::input).
// Room definitions, constants, and setup_gauntlet are gated behind
// the "gauntlet" feature (default-on) to allow stripping from release builds.
pub mod test_world;
+12 -2
View File
@@ -114,8 +114,18 @@ fn main() {
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
// Gauntlet content loader is future scope — proof room for all modes. // Gauntlet test world for --test-mode, proof room for normal mode.
setup_proof_room(&mut app); if test_mode {
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);
#[cfg(not(feature = "gauntlet"))]
{
eprintln!("--test-mode requires the 'gauntlet' feature");
std::process::exit(1);
}
} else {
setup_proof_room(&mut app);
}
tracing::info!( tracing::info!(
"Simulation initialized (seed={}, test_mode={})", "Simulation initialized (seed={}, test_mode={})",
+294
View File
@@ -11,6 +11,7 @@ use crate::simulation::inventory::{
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate}; use crate::simulation::time::{SimulationTime, TickRate};
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -91,6 +92,8 @@ pub fn process_player_input(
>, >,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
all_positions: Query<&TilePosition>, all_positions: Query<&TilePosition>,
reset_triggers: Query<&RoomResetTrigger>,
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
) { ) {
let current_tick = time.tick; let current_tick = time.tick;
let paused = time.paused(); let paused = time.paused();
@@ -193,6 +196,16 @@ pub fn process_player_input(
target_entity_id, target_entity_id,
); );
} }
Some("Reset") => {
handle_reset(
&mut commands,
&registry,
&reset_triggers,
&mut room_snapshots,
target_entity_id,
current_tick,
);
}
_ => { _ => {
tracing::info!( tracing::info!(
"Interact: target={:?}, verb={:?} — logged only", "Interact: target={:?}, verb={:?} — logged only",
@@ -438,6 +451,67 @@ fn handle_place(
); );
} }
/// Handle Reset verb: restore a room's entities to their initial positions.
/// Target entity must have a RoomResetTrigger component. Respects debounce.
fn handle_reset(
commands: &mut Commands,
registry: &EntityRegistry,
reset_triggers: &Query<&RoomResetTrigger>,
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
target_entity_id: Option<u64>,
current_tick: u64,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Reset verb without target_entity_id");
return;
};
let Some(snapshots) = room_snapshots.as_mut() else {
tracing::warn!("Reset verb but RoomSnapshots resource not available");
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Reset: target entity not in registry");
return;
};
let Ok(trigger) = reset_triggers.get(target_entity) else {
tracing::warn!(target_id, "Reset: target is not a reset trigger");
return;
};
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
tracing::info!(
room = trigger.room_name.as_str(),
"Reset: debounced or unknown room"
);
return;
};
let mut restored = 0;
for (entity, position, is_floor_item) in changes {
if is_floor_item {
commands
.entity(entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(position);
} else {
commands.entity(entity).insert(position);
}
restored += 1;
}
tracing::info!(
room = trigger.room_name.as_str(),
restored,
current_tick,
"Room reset executed via Reset verb"
);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1396,4 +1470,224 @@ mod tests {
schedule.add_systems(process_player_input); schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic schedule.run(&mut world); // should not panic
} }
// === Room Reset Tests (#490) ===
#[test]
fn reset_verb_restores_floor_item() {
// #490: Take a floor item, then Reset verb restores it to original position.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Player
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(player);
// Floor item at (5, 4)
let item = world
.spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into())))
.id();
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item);
// Reset plate entity
let plate = world
.spawn((
crate::simulation::interaction::Interactable,
RoomResetTrigger {
room_name: "test_room".to_string(),
},
TilePosition::new(5, 3, 0),
))
.id();
let plate_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(plate);
// Record snapshot: item is a floor item at its original position
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true);
world.insert_resource(snapshots);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Take the item
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
schedule.run(&mut world);
assert!(
world.get::<TilePosition>(item).is_none(),
"Item should be picked up"
);
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
// Step 2: Reset via verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
// Item should be back on the ground at original position
let pos = world
.get::<TilePosition>(item)
.expect("Item should be restored to ground");
assert_eq!(
*pos,
TilePosition::new(5, 4, 0),
"Item at original position"
);
assert!(
world.get::<CarriedBy>(item).is_none(),
"CarriedBy removed after reset"
);
assert!(
world.get::<InventorySlot>(item).is_none(),
"InventorySlot removed after reset"
);
}
#[test]
fn reset_verb_debounces() {
// #490: Reset debounce prevents rapid-fire resets.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let _player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
// NPC entity
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(npc);
// Reset plate
let plate = world
.spawn((
crate::simulation::interaction::Interactable,
RoomResetTrigger {
room_name: "test_room".to_string(),
},
TilePosition::new(5, 3, 0),
))
.id();
let plate_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(plate);
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false);
world.insert_resource(snapshots);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// First reset at tick 0 — should succeed
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
schedule.run(&mut world);
// Move NPC to verify debounce blocks second reset
*world.get_mut::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
// Second reset at tick 5 — should be debounced (< 10 ticks)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 5,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 5;
schedule.run(&mut world);
// NPC should still be at moved position (reset was debounced)
assert_eq!(
world.get::<TilePosition>(npc).unwrap().x,
20,
"NPC not reset — debounced"
);
// Third reset at tick 10 — should succeed
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 10,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 10;
schedule.run(&mut world);
// NPC should be back at original position
assert_eq!(
world.get::<TilePosition>(npc).unwrap().x,
10,
"NPC reset after debounce elapsed"
);
}
#[test]
fn reset_verb_without_snapshots_is_noop() {
// Reset verb when no RoomSnapshots resource exists should not panic.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
let plate = world
.spawn((
crate::simulation::interaction::Interactable,
RoomResetTrigger {
room_name: "test_room".to_string(),
},
TilePosition::new(5, 3, 0),
))
.id();
let plate_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(plate);
// No RoomSnapshots resource inserted — should be gracefully handled
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
} }
+325
View File
@@ -0,0 +1,325 @@
//! Gauntlet room and entity constants — single source of truth.
//!
//! All Gauntlet tests and room builders reference these constants instead
//! of magic numbers. Room geometry, observer positions, entity placements,
//! and StableId ranges are defined here.
//!
//! Canonical spawn order determines StableId assignment. Do NOT reorder
//! existing entries — append new rooms/entities at the end (additive-only).
//!
//! Specification source: gestalt-round3.md Section 7.
use crate::bridge::types::FacingDirection;
use crate::perception::vision_cone::Facing;
use crate::simulation::movement::TilePosition;
/// A Gauntlet room definition.
#[derive(Debug, Clone)]
pub struct GauntletRoom {
/// Room identifier string (e.g., "central_hub", "occlusion_corridor").
pub name: &'static str,
/// Top-left corner of the room (includes walls).
pub origin: TilePosition,
/// (width, height) in sim tiles (includes walls).
pub size: (i32, i32),
/// Player spawn position within this room (absolute coordinates).
pub spawn: TilePosition,
/// Golden file observer position (absolute coordinates).
pub observer: TilePosition,
/// Direction the observer faces for golden file snapshots.
pub observer_facing: Facing,
/// Reset plate location (if any). Only present in rooms with
/// corridor entrances (not the hub).
pub reset_plate: Option<TilePosition>,
}
/// A Gauntlet entity definition.
#[derive(Debug, Clone)]
pub struct GauntletEntity {
/// Entity name (e.g., "npc_guard_visible", "crate_01").
pub name: &'static str,
/// Which room this entity belongs to.
pub room: &'static str,
/// Absolute position in the Gauntlet map.
pub position: TilePosition,
/// Entity kind for classification.
pub kind: EntityKind,
/// StableId assigned to this entity.
pub stable_id: u64,
}
/// Entity classification for Gauntlet test entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityKind {
Sign,
Npc,
Object,
}
// ============================================================
// Room constants — canonical spawn order
// ============================================================
pub const HUB: GauntletRoom = GauntletRoom {
name: "central_hub",
origin: TilePosition { x: 38, y: 46, z: 0 },
size: (24, 24),
spawn: TilePosition { x: 50, y: 58, z: 0 },
observer: TilePosition { x: 50, y: 58, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: None,
};
pub const FOG_THEATER: GauntletRoom = GauntletRoom {
name: "fog_theater",
origin: TilePosition { x: 28, y: 2, z: 0 },
size: (44, 32),
spawn: TilePosition { x: 56, y: 18, z: 0 },
observer: TilePosition { x: 56, y: 18, z: 0 },
observer_facing: Facing(FacingDirection::South),
reset_plate: Some(TilePosition { x: 50, y: 34, z: 0 }),
};
pub const OCCLUSION_CORRIDOR: GauntletRoom = GauntletRoom {
name: "occlusion_corridor",
origin: TilePosition { x: 74, y: 48, z: 0 },
size: (42, 22),
spawn: TilePosition { x: 84, y: 58, z: 0 },
observer: TilePosition { x: 84, y: 58, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 73, y: 58, z: 0 }),
};
pub const INVENTORY_WAREHOUSE: GauntletRoom = GauntletRoom {
name: "inventory_warehouse",
origin: TilePosition { x: 2, y: 40, z: 0 },
size: (30, 28),
spawn: TilePosition { x: 17, y: 54, z: 0 },
observer: TilePosition { x: 17, y: 54, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 33, y: 58, z: 0 }),
};
pub const INTERACTION_GALLERY: GauntletRoom = GauntletRoom {
name: "interaction_gallery",
origin: TilePosition { x: 2, y: 82, z: 0 },
size: (24, 20),
spawn: TilePosition { x: 14, y: 92, z: 0 },
observer: TilePosition { x: 14, y: 92, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 14, y: 82, z: 0 }),
};
pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
name: "pause_chamber",
origin: TilePosition { x: 42, y: 78, z: 0 },
size: (16, 16),
spawn: TilePosition { x: 50, y: 86, z: 0 },
observer: TilePosition { x: 50, y: 86, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 50, y: 77, z: 0 }),
};
pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom {
name: "dialogue_room",
origin: TilePosition { x: 36, y: 104, z: 0 },
size: (28, 20),
spawn: TilePosition { x: 50, y: 114, z: 0 },
observer: TilePosition { x: 50, y: 114, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 50, y: 103, z: 0 }),
};
pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
name: "crowd_plaza",
origin: TilePosition { x: 80, y: 78, z: 0 },
size: (32, 32),
spawn: TilePosition { x: 96, y: 94, z: 0 },
observer: TilePosition { x: 96, y: 94, z: 0 },
observer_facing: Facing(FacingDirection::West),
reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }),
};
/// All rooms in canonical spawn order.
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
/// Do not reorder existing entries. Append new rooms at the end.
pub const ROOMS: &[GauntletRoom] = &[
HUB,
FOG_THEATER,
OCCLUSION_CORRIDOR,
INVENTORY_WAREHOUSE,
INTERACTION_GALLERY,
PAUSE_CHAMBER,
DIALOGUE_ROOM,
CROWD_PLAZA,
];
/// Look up which room a position falls in.
/// Returns the first room whose bounding box contains the position.
///
/// Assumptions:
/// - All rooms are at z=0 (single-floor Gauntlet). Positions on other
/// z-levels will never match.
/// - Room bounding boxes must not overlap (verified by `rooms_do_not_overlap`
/// test). Corridors are NOT rooms and intentionally fall outside all
/// bounding boxes — `room_at` returns None for corridor positions.
pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> {
ROOMS.iter().find(|r| {
pos.x >= r.origin.x
&& pos.x < r.origin.x + r.size.0
&& pos.y >= r.origin.y
&& pos.y < r.origin.y + r.size.1
&& pos.z == r.origin.z
})
}
// ============================================================
// StableId range constants
// ============================================================
/// Player entity always gets StableId 0.
pub const PLAYER_STABLE_ID: u64 = 0;
/// StableId ranges per room (start, end inclusive).
pub const HUB_STABLE_IDS: (u64, u64) = (1, 4);
pub const FOG_THEATER_STABLE_IDS: (u64, u64) = (5, 8);
pub const OCCLUSION_STABLE_IDS: (u64, u64) = (9, 12);
pub const INVENTORY_STABLE_IDS: (u64, u64) = (13, 23);
pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51);
/// Number of actively-spawned entities in the current Gauntlet build.
/// Derived from StableId ranges of built rooms + player + reset plates.
/// Reserved (unbuilt) rooms do not contribute entities.
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
+ (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize
+ (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_at_finds_hub() {
let pos = TilePosition { x: 50, y: 58, z: 0 };
let room = room_at(&pos).expect("Hub center should be in a room");
assert_eq!(room.name, "central_hub");
}
#[test]
fn room_at_finds_occlusion_corridor() {
let pos = TilePosition { x: 84, y: 58, z: 0 };
let room = room_at(&pos).expect("Occlusion observer should be in a room");
assert_eq!(room.name, "occlusion_corridor");
}
#[test]
fn room_at_finds_inventory_warehouse() {
let pos = TilePosition { x: 17, y: 54, z: 0 };
let room = room_at(&pos).expect("Inventory observer should be in a room");
assert_eq!(room.name, "inventory_warehouse");
}
#[test]
fn room_at_finds_pause_chamber() {
let pos = TilePosition { x: 50, y: 86, z: 0 };
let room = room_at(&pos).expect("Pause observer should be in a room");
assert_eq!(room.name, "pause_chamber");
}
#[test]
fn room_at_returns_none_for_corridor() {
// Point inside corridor-E (between Hub and Occlusion)
let pos = TilePosition { x: 66, y: 57, z: 0 };
assert!(room_at(&pos).is_none(), "Corridor should not be in any room");
}
#[test]
fn room_at_returns_none_for_outside_map() {
let pos = TilePosition { x: 200, y: 200, z: 0 };
assert!(room_at(&pos).is_none());
}
#[test]
fn all_rooms_in_correct_order() {
assert_eq!(ROOMS.len(), 8);
assert_eq!(ROOMS[0].name, "central_hub");
assert_eq!(ROOMS[1].name, "fog_theater");
assert_eq!(ROOMS[2].name, "occlusion_corridor");
assert_eq!(ROOMS[3].name, "inventory_warehouse");
assert_eq!(ROOMS[4].name, "interaction_gallery");
assert_eq!(ROOMS[5].name, "pause_chamber");
assert_eq!(ROOMS[6].name, "dialogue_room");
assert_eq!(ROOMS[7].name, "crowd_plaza");
}
#[test]
fn rooms_do_not_overlap() {
for (i, a) in ROOMS.iter().enumerate() {
for (j, b) in ROOMS.iter().enumerate() {
if i >= j {
continue;
}
let overlap_x = a.origin.x < b.origin.x + b.size.0
&& a.origin.x + a.size.0 > b.origin.x;
let overlap_y = a.origin.y < b.origin.y + b.size.1
&& a.origin.y + a.size.1 > b.origin.y;
assert!(
!(overlap_x && overlap_y),
"Rooms {} and {} overlap",
a.name, b.name
);
}
}
}
#[test]
fn observer_inside_room() {
for room in ROOMS {
let obs = &room.observer;
assert!(
obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0,
"Observer x={} outside room {} (origin.x={}, width={})",
obs.x, room.name, room.origin.x, room.size.0
);
assert!(
obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1,
"Observer y={} outside room {} (origin.y={}, height={})",
obs.y, room.name, room.origin.y, room.size.1
);
}
}
#[test]
fn stable_id_ranges_non_overlapping() {
let ranges = [
HUB_STABLE_IDS,
FOG_THEATER_STABLE_IDS,
OCCLUSION_STABLE_IDS,
INVENTORY_STABLE_IDS,
INTERACTION_GALLERY_STABLE_IDS,
PAUSE_CHAMBER_STABLE_IDS,
DIALOGUE_ROOM_STABLE_IDS,
CROWD_PLAZA_STABLE_IDS,
RESET_PLATE_STABLE_IDS,
];
for (i, a) in ranges.iter().enumerate() {
for (j, b) in ranges.iter().enumerate() {
if i >= j {
continue;
}
assert!(
a.1 < b.0 || b.1 < a.0,
"StableId ranges {} and {} overlap: {:?} vs {:?}",
i, j, a, b
);
}
}
}
}
+366
View File
@@ -0,0 +1,366 @@
//! Gauntlet test world — purpose-built rooms for systematic QA testing.
//!
//! NOT production content. This module provides deterministic room layouts
//! with precise entity placement for golden file testing, regression testing,
//! and manual QA sessions. Loaded instead of content/ when the server runs
//! the Gauntlet map.
//!
//! Module structure:
//! - `reset` — always compiled (used by simulation::input in production)
//! - `constants`, `rooms`, `setup_gauntlet` — gated behind "gauntlet" feature
//! (default-on; strip with --no-default-features for release builds)
//!
//! Architecture (per workshop-outcomes.md Section 4):
//! - Rooms are defined as Rust builder functions (hybrid YAML + Rust inject)
//! - WalkabilityMap covers the full Gauntlet bounds (0-116 x 0-124)
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8 (reserved, not yet built)
//! Occlusion Corridor: 9-12
//! Inventory Warehouse: 13-23
//! Interaction Gallery: 24-28 (reserved, not yet built)
//! Pause Chamber: 29
//! Dialogue Room: 30-33 (reserved, not yet built)
//! Crowd Plaza: 34-48 (reserved, not yet built)
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
#[cfg(feature = "gauntlet")]
pub mod constants;
pub mod reset;
#[cfg(feature = "gauntlet")]
pub mod rooms;
#[cfg(feature = "gauntlet")]
use bevy_app::prelude::*;
#[cfg(feature = "gauntlet")]
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
#[cfg(feature = "gauntlet")]
use crate::knowledge::KnowledgeGraph;
#[cfg(feature = "gauntlet")]
use crate::knowledge::types::StableId;
#[cfg(feature = "gauntlet")]
use crate::perception::cognitive_delay::CognitiveDelay;
#[cfg(feature = "gauntlet")]
use crate::perception::vision_cone::Facing;
#[cfg(feature = "gauntlet")]
use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer};
#[cfg(feature = "gauntlet")]
use crate::simulation::inventory::ItemName;
#[cfg(feature = "gauntlet")]
use crate::simulation::listening::ListeningFocus;
#[cfg(feature = "gauntlet")]
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
#[cfg(feature = "gauntlet")]
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
#[cfg(feature = "gauntlet")]
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
#[cfg(feature = "gauntlet")]
use reset::{RoomResetTrigger, RoomSnapshots};
#[cfg(feature = "gauntlet")]
/// Map bounds for the full Gauntlet world.
pub const MAP_WIDTH: i32 = 117;
#[cfg(feature = "gauntlet")]
pub const MAP_HEIGHT: i32 = 125;
/// Set up the Gauntlet test world.
///
/// Creates the full WalkabilityMap (blocked by default), carves room
/// interiors and corridors, spawns the player and all room entities
/// in canonical StableId order.
///
/// NOTE: This bypasses the normal startup system scheduler — entities are
/// spawned directly into the World rather than via startup systems. This
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
pub fn setup_gauntlet(app: &mut App) {
// Start with a fully blocked map, then carve rooms and corridors.
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
// Set up Occlusion Corridor walls (relative positions converted to absolute)
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
for x in 88..=96 {
for y in 54..=55 {
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
}
}
// South alcove walls: rel x=[4,8], y=[12,13]
for x in 78..=82 {
for y in 60..=61 {
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
}
}
app.insert_resource(walkability);
// Entity spawning in canonical StableId order.
// Player gets StableId 0, then entities by room in workshop order.
let mut registry = EntityRegistry::new(0);
// --- Player (StableId 0) ---
// Spawn at Hub center: absolute (50, 58)
let profile = MovementProfile::smuggler();
let player_pos = TilePosition::new(50, 58, 0);
let player = app
.world_mut()
.spawn((
PlayerCharacter,
player_pos,
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(player_pos),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
// --- Hub signs (StableId 1-4) ---
rooms::hub::spawn_entities(app, &mut registry);
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
registry.reserve_up_to(9);
// --- Occlusion Corridor (StableId 9-12) ---
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
// --- Inventory Warehouse (StableId 13-23) ---
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
registry.reserve_up_to(29);
// --- Pause Chamber (StableId 29) ---
rooms::pause_chamber::spawn_entities(app, &mut registry);
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
registry.reserve_up_to(49);
// --- Reset plates (StableId 49-51) ---
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
// Each plate triggers reset of its associated room.
let reset_plates: &[(&str, TilePosition)] = &[
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
];
for &(room_name, pos) in reset_plates {
let entity = app
.world_mut()
.spawn((
Interactable,
RoomResetTrigger {
room_name: room_name.to_string(),
},
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
for id in 9..=12 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("occlusion_corridor", entity, *pos, false);
}
}
}
// Inventory Warehouse entities (StableId 13-23): crates are floor items, NPC is not
for id in 13..=23 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
let is_floor_item = app.world().get::<ItemName>(entity).is_some();
snapshots.record("inventory_warehouse", entity, *pos, is_floor_item);
}
}
}
// Pause Chamber entity (StableId 29): NPC only
if let Some(entity) = registry.to_entity(&StableId(29)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("pause_chamber", entity, *pos, false);
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
#[cfg(feature = "gauntlet")]
/// Carve the walkable interior of a room.
/// Room has 2-tile-thick walls; interior starts 2 tiles inside each boundary.
fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
for y in (oy + 2)..(oy + h - 2) {
for x in (ox + 2)..(ox + w - 2) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(feature = "gauntlet")]
/// Carve a corridor as a fully walkable rectangle.
/// Corridors are 6 tiles wide with walls on both sides — we carve the
/// interior 2 tiles in from each edge (leaving 2-tile walkable center).
/// For narrow corridors (width=6), interior is 2 tiles wide.
fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
// Corridors are fully walkable rectangles (wall tiles are the
// surrounding room/map boundary). Carve interior with 1-tile margin.
for y in (oy + 1)..(oy + h - 1) {
for x in (ox + 1)..(ox + w - 1) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(all(test, feature = "gauntlet"))]
mod tests {
use super::*;
use crate::knowledge::registry::EntityRegistry;
#[test]
fn gauntlet_setup_creates_expected_entities() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
assert_eq!(
registry.len(),
constants::EXPECTED_ENTITY_COUNT,
"entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges"
);
}
#[test]
fn hub_center_is_walkable() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// Hub center at (50, 58) must be walkable
assert!(wm.can_move_to(&TilePosition::new(50, 58, 0)));
}
#[test]
fn occlusion_north_wall_blocks() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// North wall segment at absolute (90, 54) should be blocked
assert!(!wm.can_move_to(&TilePosition::new(90, 54, 0)));
// But the corridor interior at (90, 56) should be walkable
assert!(wm.can_move_to(&TilePosition::new(90, 56, 0)));
}
#[test]
fn corridor_connects_hub_to_occlusion() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// corridor-E center should be walkable
assert!(wm.can_move_to(&TilePosition::new(66, 57, 0)));
}
#[test]
fn stable_id_ranges_match_spec() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
// Player at StableId 0
use crate::knowledge::types::StableId;
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
// Hub signs at 1-4
for id in 1..=4 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
}
// Fog Theater 5-8 reserved (no entities)
for id in 5..=8 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
}
// Occlusion Corridor at 9-12
for id in 9..=12 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id);
}
// Inventory Warehouse at 13-23
for id in 13..=23 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
}
// Interaction Gallery 24-28 reserved
for id in 24..=28 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
}
// Pause Chamber at 29
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
// Reset plates at 49-51
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Reset plate at StableId {}",
id
);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Room reset trigger mechanism (#490).
//!
//! Restores a room's entities to their initial positions when the player
//! interacts with a reset plate. Used during Gauntlet QA sessions to
//! re-test a room without restarting the server.
//!
//! Components:
//! - `RoomResetTrigger` — marks an entity as a reset plate for a room
//!
//! Resource:
//! - `RoomSnapshots` — stores tick-0 entity positions per room
//!
//! Production path: `plan_reset` returns planned changes as a Vec, which
//! the input system applies via Commands (see simulation::input). This
//! avoids exclusive World access and is scheduler-friendly.
//!
//! Spec (workshop-outcomes.md Section 8):
//! - Trigger: Interact with reset plate entity (verb "Reset")
//! - Resets: entity positions, carried items from room returned to floor
//! - Does NOT reset: other rooms, player position, session timer, SimRng
//! - Debounce: 10-tick cooldown per room
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use crate::simulation::movement::TilePosition;
/// Debounce cooldown in ticks between resets of the same room.
pub const RESET_DEBOUNCE_TICKS: u64 = 10;
/// Marks an entity as a room reset trigger (reset plate).
/// The player interacts with this entity using the "Reset" verb
/// to restore the room to its initial state.
#[derive(Component, Debug, Clone)]
pub struct RoomResetTrigger {
pub room_name: String,
}
/// Snapshot of a single entity's initial position.
#[derive(Debug, Clone)]
struct EntitySnapshot {
entity: Entity,
position: TilePosition,
/// Whether this entity was a floor item (had TilePosition + ItemName at spawn).
is_floor_item: bool,
}
/// Per-room initial state snapshot.
#[derive(Debug, Clone, Default)]
struct RoomSnapshotData {
entities: Vec<EntitySnapshot>,
}
/// Resource holding initial entity state per room and debounce tracking.
#[derive(Resource, Debug, Default)]
pub struct RoomSnapshots {
snapshots: BTreeMap<String, RoomSnapshotData>,
last_reset_tick: BTreeMap<String, u64>,
}
impl RoomSnapshots {
/// Record the initial position of an entity in a room.
pub fn record(&mut self, room_name: &str, entity: Entity, position: TilePosition, is_floor_item: bool) {
self.snapshots
.entry(room_name.to_string())
.or_default()
.entities
.push(EntitySnapshot {
entity,
position,
is_floor_item,
});
}
/// Check if a reset is allowed (debounce check).
pub fn can_reset(&self, room_name: &str, current_tick: u64) -> bool {
match self.last_reset_tick.get(room_name) {
Some(&last) => current_tick >= last + RESET_DEBOUNCE_TICKS,
None => true,
}
}
/// Plan a room reset for use with Commands (system-friendly).
/// Returns the list of (entity, position, is_floor_item) changes to apply,
/// or None if debounced or unknown room. Updates debounce tracking.
///
/// This is the canonical production API — the input system applies the
/// returned changes via Commands to avoid exclusive World access.
pub fn plan_reset(
&mut self,
room_name: &str,
current_tick: u64,
) -> Option<Vec<(Entity, TilePosition, bool)>> {
if !self.can_reset(room_name, current_tick) {
return None;
}
let snapshot = self.snapshots.get(room_name)?;
let changes: Vec<_> = snapshot
.entities
.iter()
.map(|s| (s.entity, s.position, s.is_floor_item))
.collect();
self.last_reset_tick
.insert(room_name.to_string(), current_tick);
Some(changes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_reset_returns_correct_changes() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
let changes = snapshots.plan_reset("test_room", 0);
assert!(changes.is_some());
let changes = changes.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].0, entity);
assert_eq!(changes[0].1, TilePosition::new(10, 20, 0));
assert!(!changes[0].2); // not a floor item
}
#[test]
fn plan_reset_includes_floor_items() {
let mut world = World::new();
let item = world.spawn(TilePosition::new(5, 5, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("warehouse", item, TilePosition::new(5, 5, 0), true);
let changes = snapshots.plan_reset("warehouse", 0).unwrap();
assert_eq!(changes.len(), 1);
assert!(changes[0].2); // is a floor item
}
#[test]
fn plan_reset_debounces() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
// First reset at tick 0
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Second reset at tick 5 — should be debounced
assert!(snapshots.plan_reset("test_room", 5).is_none());
// Third reset at tick 10 — should succeed
assert!(snapshots.plan_reset("test_room", 10).is_some());
}
#[test]
fn plan_reset_debounce_exact_boundary() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
// Reset at tick 0
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Tick 9: exactly one tick before debounce expires — must be rejected
assert!(
snapshots.plan_reset("test_room", 9).is_none(),
"tick 9 should be rejected (debounce is 10 ticks)"
);
// Tick 10: exact debounce boundary — must be accepted
assert!(
snapshots.plan_reset("test_room", 10).is_some(),
"tick 10 should be accepted (debounce elapsed)"
);
}
#[test]
fn plan_reset_unknown_room_returns_none() {
let mut snapshots = RoomSnapshots::default();
assert!(snapshots.plan_reset("nonexistent", 0).is_none());
}
#[test]
fn can_reset_fresh_room() {
let snapshots = RoomSnapshots::default();
assert!(snapshots.can_reset("any_room", 0));
}
#[test]
fn can_reset_after_debounce() {
let mut snapshots = RoomSnapshots::default();
snapshots
.last_reset_tick
.insert("room".to_string(), 100);
assert!(!snapshots.can_reset("room", 105));
assert!(snapshots.can_reset("room", 110));
assert!(snapshots.can_reset("room", 200));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Central Hub — Room 0 (24x24)
//!
//! Connector room with 4 directional sign markers. No test entities.
//! Hub spawn point: (12, 12) relative = (50, 58) absolute.
//!
//! Entities (StableId 1-4):
//! sign_north (50, 48) — "Fog Theater"
//! sign_east (60, 58) — "Occlusion Corridor"
//! sign_south (50, 68) — "Pause Chamber"
//! sign_west (40, 58) — "Inventory Warehouse"
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 38;
const ORIGIN_Y: i32 = 46;
/// Sign positions as relative offsets from room origin.
const SIGNS: &[(&str, i32, i32)] = &[
("sign_north", 12, 2), // North marker
("sign_east", 22, 12), // East marker
("sign_south", 12, 22), // South marker
("sign_west", 2, 12), // West marker
];
/// Spawn hub sign entities in canonical order (StableId 1-4).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(_name, rx, ry) in SIGNS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app.world_mut().spawn((Interactable, pos)).id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,88 @@
//! Inventory Warehouse — Room 3 (30x28)
//!
//! Tests D-065 (9-slot inventory), pickup/drop verbs, CarriedBy component.
//!
//! Layout: Open warehouse floor with 10 item crates in a grid pattern
//! and 1 NPC near the back wall for interaction testing while carrying items.
//!
//! Observer position: (15, 14) relative = (17, 54) absolute, facing East.
//!
//! Entities (StableId 13-23):
//! crate_01..crate_10 — Floor items (Container ObjectType)
//! npc_warehouse — Interaction target
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::inventory::ItemName;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 2;
const ORIGIN_Y: i32 = 40;
/// Item crate definitions: (name, relative_x, relative_y, item_name).
/// 10 crates for testing the 9-slot inventory limit.
const CRATES: &[(&str, i32, i32, &str)] = &[
("crate_01", 4, 4, "Keycard"),
("crate_02", 8, 4, "Manifest"),
("crate_03", 12, 4, "Datapad"),
("crate_04", 16, 4, "Toolkit"),
("crate_05", 4, 10, "Badge"),
("crate_06", 8, 10, "Medkit"),
("crate_07", 12, 10, "Ration"),
("crate_08", 16, 10, "Cable"),
("crate_09", 4, 16, "Seal"),
("crate_10", 8, 16, "Chip"),
];
/// Spawn Inventory Warehouse entities in canonical order (StableId 13-23).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
// Crates (StableId 13-22)
for &(_name, rx, ry, item_name) in CRATES {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Interactable,
ObjectType::Container,
ItemName(item_name.to_string()),
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// Warehouse NPC (StableId 23)
let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 14, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
npc_pos,
Want {
primary: WantKind::Wealth,
intensity: 5,
description: "Warehouse supervisor wants efficiency".to_string(),
},
Contentment { level: 10 },
ToleranceThreshold {
current_stress: 15,
threshold: 65,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
+9
View File
@@ -0,0 +1,9 @@
//! Gauntlet room builders.
//!
//! Each room module exports a `spawn_entities()` function that creates
//! entities in canonical order for deterministic StableId assignment.
pub mod hub;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;
@@ -0,0 +1,66 @@
//! Occlusion Corridor — Room 2 (42x22)
//!
//! Tests D-035 (symmetric shadowcasting), D-017 (perception modes),
//! D-015 (vision cone sectors).
//!
//! Layout: Long east-west corridor with perpendicular wall segments
//! creating visibility pockets. North alcove with hidden NPC,
//! south alcove with partially visible NPC.
//!
//! Observer position: (10, 10) relative = (84, 58) absolute, facing East.
//!
//! Entities (StableId 9-12):
//! npc_guard_visible (92, 58) — Clear LOS baseline
//! npc_hidden_wall (92, 52) — Behind north wall segment
//! npc_peripheral (80, 64) — South alcove, peripheral sector
//! npc_far_end (110, 58) — Far end of corridor, tests range
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 74;
const ORIGIN_Y: i32 = 48;
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
("npc_guard_visible", 18, 10, WantKind::Safety, 5),
("npc_hidden_wall", 18, 4, WantKind::Freedom, 3),
("npc_peripheral", 6, 16, WantKind::Knowledge, 6),
("npc_far_end", 36, 10, WantKind::Wealth, 4),
];
/// Spawn Occlusion Corridor entities in canonical order (StableId 9-12).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(name, rx, ry, want_kind, intensity) in NPCS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity,
description: format!("Occlusion Corridor test NPC: {}", name),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,51 @@
//! Pause Chamber — Room 5 (16x16)
//!
//! Tests D-031 (pause/unpause), Bug #3 regression (movement while paused).
//!
//! Layout: Minimal open room with a single NPC. No visual complexity —
//! this room is pure state machine testing.
//!
//! Observer position: (8, 8) relative = (50, 86) absolute, facing North.
//!
//! Entities (StableId 29):
//! npc_pause_target — Interaction target during pause (Talk should work)
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 42;
const ORIGIN_Y: i32 = 78;
/// Spawn Pause Chamber entities in canonical order (StableId 29).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
let pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 6, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 3,
description: "Wants a quiet posting".to_string(),
},
Contentment { level: 5 },
ToleranceThreshold {
current_stress: 10,
threshold: 40,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
+5
View File
@@ -3,6 +3,8 @@
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities. //! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content, //! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning. //! and a test fixture for isolated NPC profile spawning.
//!
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
use bevy_app::prelude::*; use bevy_app::prelude::*;
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
@@ -416,3 +418,6 @@ fn spawn_real_content_with_relationships_and_secrets() {
.expect("Nils should have Want"); .expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power); assert_eq!(nils_want.primary, npc::WantKind::Power);
} }
// Runtime validation test (boot + tick 10 + snapshot) moved to
// server/tests/content_runtime.rs per architectural review.

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