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 dde68104fc chore(config): add test-client Cargo.lock and gitignore target/
Follows existing tooling crate pattern (content-converter, line-previewer).

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:28 +01:00
215 changed files with 21616 additions and 203 deletions
+5 -1
View File
@@ -178,7 +178,11 @@ For design patterns, consult:
**Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:**
- `name`: The skill name
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
The domain is the system/area the skill operates on, the action is what it does.
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
Multi-action wrappers (like `ticket`) can use the domain name alone.
The directory name must match the `name` field.
- `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body).
+19
View File
@@ -103,6 +103,25 @@ tea pr create \
Report PR URL when done.
### 7. Update ticket status to review
Scan all commit messages in the PR for ticket references (`#NNN`):
```bash
git log --oneline main..<branch>
```
Extract ticket IDs from `#NNN` patterns. For each ticket that is
currently `in_progress`, update it to `review`:
```bash
db/connectors/ticket status <id> review
```
Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
+4
View File
@@ -140,6 +140,10 @@ Respond with:
- File path and approximate location
- Severity: critical / warning / suggestion
- Description of the issue
Verdict rules:
- Any **critical** or **warning** issue → REQUEST_CHANGES
- Only **suggestion**-level issues (or none) → APPROVE
If no issues found, say APPROVE with a brief positive summary.
```
+78 -10
View File
@@ -1,10 +1,12 @@
---
name: start-sprint
description: >
Start sprint work on a team branch. Use when the user says "start sprint",
"start working on the server/client/copy", "begin sprint", or invokes
/start-sprint. Merges main into the team branch, finds the active sprint,
reads the sprint briefing, and presents the work plan with ticket details.
Start sprint work on a team branch, or close a sprint from main. Use when
the user says "start sprint", "start working on the server/client/copy",
"begin sprint", or invokes /start-sprint. On a team branch: merges main,
finds the active sprint, reads the briefing, presents the work plan. On
main: closes the active sprint, bumps the version (v0.1.N), updates the
changelog, tags, and pushes.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
@@ -24,7 +26,68 @@ git branch --show-current
```
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
If on `main`, ask the user which team branch to check out first.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 2–8).
---
## Main branch workflow (sprint close + version bump)
When `/start-sprint` is run on `main`, it means the user wants to close
the current sprint, cut a version, and prepare for the next one.
### M1. Close the active sprint
```bash
db/connectors/sprint stop
```
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
### M2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
- Leave `## [Unreleased]` as an empty section above the new version.
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
### M3. Commit the release
Stage and commit `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
### M4. Tag the release
```bash
git tag v0.1.N
```
### M5. Push
```bash
git push && git push --tags
```
### M6. Report
Output a summary:
- Sprint closed (name, done/total tickets, carry-over count)
- Version tagged (`v0.1.N`)
- Carry-over candidates (if any)
- Suggest running `/plan-sprint` next to prepare the next sprint
---
## Team branch workflow
### 2. Sync with main
@@ -68,18 +131,23 @@ db/connectors/ticket show <id>
Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work.
### 7. Present the work plan
### 7. Mark tickets in progress and present the work plan
Output a summary:
Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
db/connectors/ticket status <id> in_progress
```
Then output a summary:
- Sprint name and goal
- Branch status (clean merge or conflicts)
- Actionable tickets (unblocked, ready to start)
- Tickets marked in_progress (list with IDs)
- Blocked tickets (and what blocks them)
- Key decisions loaded
- Suggested first task (lowest ID unblocked ticket)
Do NOT mark any ticket as `in_progress` yet.
### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
+1
View File
@@ -3,6 +3,7 @@
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
tooling/test-client/target/
content-ron/
# Godot client (further ignores managed by client team)
+52 -2
View File
@@ -6,12 +6,62 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
## [v0.1.9] — 2026-02-18
### Fixed
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
### Added
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
### Changed
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
### Added
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
- Domain-action naming convention for skills documented in create-skill guide
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
- Reset plate StableId verification in `stable_id_ranges_match_spec`
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
### Changed
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
- `room_at()` documented with z-range and corridor overlap assumptions
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
### Removed
- Dead `RoomMember` component from reset.rs (defined but never used)
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
### Fixed
- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf
- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review)
- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review)
- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway)
- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures
- Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch
- Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb }
- Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
- Branch-specific pre-PR variants: `make pre-pr-server`, `make pre-pr-client`, `make pre-pr-content`
- Content cross-reference validation (9 checks) — canonical_id uniqueness, relationship targets, location slugs, dialogue locations, fact_ids, triangle membership, npc_count, dialogue line_ids, bidirectional relationships (#464)
+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 \
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
# --- Configuration ---
@@ -37,6 +39,11 @@ help:
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make content-ron Convert content YAML to RON (build-time)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@echo " make checklist-validate Validate checklist YAML against schema"
@echo " make checklist-generate Validate checklists + print condition summary"
@echo ""
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
@@ -63,6 +70,8 @@ setup-tooling:
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; }
@command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; }
@command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; }
@python3 -c "import yaml" 2>/dev/null || { echo "Install PyYAML: pip install pyyaml"; exit 1; }
@python3 -c "import jsonschema" 2>/dev/null || { echo "Install jsonschema: pip install jsonschema"; exit 1; }
setup-hooks:
@git config core.hooksPath .config/hooks
@@ -77,7 +86,7 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client --quit
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -88,7 +97,7 @@ client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --path client
game: stop build-server
game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@@ -111,6 +120,41 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
$(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd
@echo "Verifying Rust can decode GDScript fixtures..."
cd server && cargo test --test serialization gdscript_generated_fixtures
@echo "--- Fixtures-client: PASS ---"
golden-diff:
@echo "Regenerating golden output for comparison..."
@mkdir -p .cache/golden
@cp server/tests/golden/proof_room_tick_10.json .cache/golden/before.json
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden; \
rc=$$?; \
cp ../server/tests/golden/proof_room_tick_10.json ../.cache/golden/after.json 2>/dev/null; \
cp ../.cache/golden/before.json ../server/tests/golden/proof_room_tick_10.json; \
if [ $$rc -ne 0 ]; then echo "Cargo test failed (golden file restored)."; exit $$rc; fi
@if diff -q .cache/golden/before.json .cache/golden/after.json >/dev/null 2>&1; then \
echo "--- Golden file: UP TO DATE ---"; \
else \
echo "--- Golden file has CHANGED ---"; \
echo ""; \
diff --color -u .cache/golden/before.json .cache/golden/after.json || true; \
echo ""; \
echo "Run 'make golden-update' to accept changes."; \
exit 1; \
fi
golden-update:
@echo "Regenerating golden file..."
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
@git add server/tests/golden/
@echo "--- Golden file updated and staged ---"
@echo "Review with: git diff --cached -- server/tests/golden/"
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
@@ -148,13 +192,13 @@ pre-pr-validate: validate-content check-fact-ids
@echo "--- Content validation: PASS ---"
pre-pr-fixtures:
@echo "Checking fixture staleness..."
@echo "Checking Rust->GDScript fixture staleness..."
@cd server && cargo test --test gen_fixtures -- --ignored
@if git diff --quiet client/tests/fixtures/; then \
echo "--- Fixtures: UP TO DATE ---"; \
echo "--- Rust fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- FIXTURES STALE ---"; \
echo "--- RUST FIXTURES STALE ---"; \
echo " Protocol changed but fixtures not regenerated."; \
echo " Stale fixtures make all client tests FALSE POSITIVES."; \
echo ""; \
@@ -164,6 +208,22 @@ pre-pr-fixtures:
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
@echo "Checking GDScript->Rust fixture staleness..."
@$(MAKE) fixtures-client
@if git diff --quiet server/tests/fixtures/gdscript/; then \
echo "--- GDScript fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- GDSCRIPT FIXTURES STALE ---"; \
echo " Protocol changed but GDScript fixtures not regenerated."; \
echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \
echo ""; \
echo " Changed files:"; \
git diff --stat server/tests/fixtures/gdscript/; \
echo ""; \
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
# Branch-specific variants (faster, scope-appropriate)
@@ -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
@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 ==="
# --- CI (run locally) ---
@@ -214,6 +274,12 @@ validate-content:
check-fact-ids:
@tooling/check-fact-ids
checklist-validate:
@tooling/validate-checklist --check
checklist-generate:
@tooling/validate-checklist
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -223,5 +289,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot client/reports
rm -rf client/.godot/* client/reports
@echo "Clean complete."
+11 -4
View File
@@ -87,7 +87,8 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
elif 0 <= value and value <= (1 << 32) - 1:
buffer.put_u8(types["uint_32"])
buffer.put_u32(value)
elif - (1 << 63) <= value and value < (1 << 63):
elif value < 0:
# Negative beyond int_32 range — encode as int_64 (0xd3)
buffer.put_u8(types["int_64"])
buffer.put_64(value)
else:
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for obj in value:
_encode_message(buffer, obj)
var inner_err = _encode_message(buffer, obj)
if inner_err:
return inner_err
TYPE_DICTIONARY:
var size = value.size()
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for key in value:
_encode_message(buffer, key)
_encode_message(buffer, value[key])
var key_err = _encode_message(buffer, key)
if key_err:
return key_err
var val_err = _encode_message(buffer, value[key])
if val_err:
return val_err
TYPE_PACKED_BYTE_ARRAY:
var size = value.size()
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)
]
}
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]
+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/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/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -131,6 +133,9 @@ layer = 20
; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -140,6 +145,8 @@ script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
[node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
@@ -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.
var dialogue_active: bool = false
# v8 fields (#496): Gauntlet mode — room timer + personal bests
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -118,6 +122,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
pending_recognitions = []
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
gauntlet_mode = true
else:
gauntlet_mode = false
if snapshot.has("room_id") and snapshot.room_id is String:
room_id = snapshot.room_id
else:
room_id = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+3
View File
@@ -14,6 +14,7 @@ enum Action {
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
}
var input_queue: Array[Dictionary] = []
@@ -74,6 +75,8 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_UP
elif event.is_action_pressed("stance_down"):
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
if action != -1:
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_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
return ""
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
+21
View File
@@ -12,6 +12,8 @@ extends Node2D
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
@@ -49,6 +51,10 @@ func _ready() -> void:
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
@@ -88,6 +94,10 @@ func _process(_delta: float) -> void:
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -109,6 +119,11 @@ func _process(_delta: float) -> void:
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
if input.action == InputMapper.Action.BUG_REPORT:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -197,3 +212,9 @@ func _on_dialogue_dismissed() -> void:
"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.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 7
const PROTOCOL_VERSION: int = 8
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -169,6 +169,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"options": dialogue_options,
}
# v8: dialogue_response (#305, D-028) — NPC's spoken dialogue line from server
# Separate from current_dialogue (client-side rich dialogue state).
var dialogue_response: Variant = null
var raw_dr: Variant = raw.get("dialogue_response")
if raw_dr is Dictionary and raw_dr.has("text"):
dialogue_response = {
"line_id": str(raw_dr.get("line_id", "")),
"text": str(raw_dr["text"]),
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
return {
"tick": tick,
"entities": entities,
@@ -182,6 +193,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
"current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions,
}
@@ -293,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action_value: Variant
if action_data != null:
# Data variant → single-element map
action_value = { action_name: action_data }
else:
# Unit variant → bare string
action_value = action_name
var action_value: Variant = _encode_action(action_name, action_data)
var input := {
"tick": tick,
@@ -322,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action_value: Variant
if action_data != null:
action_value = { action_name: action_data }
else:
action_value = action_name
wire_inputs.append({
"tick": input["tick"],
"action": action_value,
"action": _encode_action(action_name, action_data),
})
var result = Messagepack.encode(wire_inputs)
@@ -340,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
return result.value
## Encode a PlayerAction for the wire.
## Struct variants (Interact) always need their fields even when null.
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
if action_data != null:
return { action_name: action_data }
# Interact is a struct variant — server expects named fields, not a bare string
if action_name == "Interact":
return { "Interact": { "target_entity_id": null, "verb": null } }
return action_name
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant:
@@ -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 --
func test_interact_encodes_as_unit_variant() -> void:
func test_interact_encodes_as_struct_variant() -> void:
# Interact is always a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
var inputs: Array = [{"tick": 100, "action_name": "Interact"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("Interact")
assert_that(raw.value[0]["action"] is Dictionary).is_true()
assert_that(raw.value[0]["action"].has("Interact")).is_true()
var interact_data: Dictionary = raw.value[0]["action"]["Interact"]
assert_that(interact_data.has("target_entity_id")).is_true()
assert_that(interact_data.has("verb")).is_true()
func test_interact_with_data_encodes_as_data_variant() -> void:
# Future: once server accepts Interact(InteractData)
var inputs: Array = [{
"tick": 100,
"action_name": "Interact",
+3 -1
View File
@@ -142,7 +142,9 @@ func test_framed_protocol_batch_input_roundtrip() -> void:
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
# -- Diagonal movement wire mapping --------------------------------------------
+6 -12
View File
@@ -85,13 +85,8 @@ func test_encode_uint32() -> void:
func test_encode_int64_positive() -> void:
# BV-P24 to BV-P25: int 64 range (2^32 to 2^63-1)
# KNOWN-DEFECT: The encoder's int_64 branch condition `-(1 << 63) <= v < (1 << 63)`
# 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`.
# BV-P24 to BV-P25: positive values > uint_32 max → uint_64 (0xcf)
# Positive values correctly use uint_64 encoding for Rust interop.
_assert_encodes_to(4294967296, PackedByteArray([
0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00
]), "BV-P24")
@@ -138,16 +133,15 @@ func test_encode_int32_negative() -> void:
func test_encode_int64_negative() -> void:
# 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).
# Bit pattern is preserved: put_u64(negative) writes two's complement,
# get_u64() reads it back and Variant stores as int64 with same bit pattern.
# Negative values beyond int_32 now correctly encode as int_64 (0xd3).
# Fixed: encoder's int_64 branch used `-(1 << 63)` which overflowed to dead code.
_assert_encodes_to(-2147483649, PackedByteArray([
0xcf, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
]), "BV-N15")
# MIN_INT64: -9223372036854775808
var min_int64: int = -9223372036854775807 - 1
_assert_encodes_to(min_int64, PackedByteArray([
0xcf, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]), "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)
# Client polls and consumes the monologue
var snapshot = SimBridge.poll_snapshot()
# Client polls and consumes the monologue.
# In live mode, poll_snapshot() returns _last_snapshot and sets it to null.
# In test mode, poll_snapshot() returns _test_snapshot() instead, so we
# simulate the live-mode consumption path directly.
var snapshot = SimBridge._last_snapshot
SimBridge._last_snapshot = null
assert_that(snapshot).is_not_null()
GameState.apply_snapshot(snapshot)
# apply_snapshot sets current_monologue, then main._consume_monologue() clears it.
# Simulate consumption:
GameState.current_monologue = null
# Next snapshot arrives without monologue — _last_snapshot is null after poll,
# Next snapshot arrives without monologue — _last_snapshot was null after poll,
# so no carry-forward should happen.
var bytes_next := _make_snapshot_bytes({"tick": 2})
SimBridge.receive_bytes(bytes_next)
+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.value.size()).is_equal(3)
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")
@@ -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]["action"]).is_equal("MoveNorth")
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) -----------------
+7 -7
View File
@@ -26,17 +26,17 @@ func _load_fixture(name: String) -> PackedByteArray:
# -- Protocol version upgrade -------------------------------------------------
func test_protocol_version_is_7() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(7)
func test_protocol_version_is_8() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(8)
func test_fixtures_at_protocol_version_7() -> void:
# All regenerated fixtures should be at v7
func test_fixtures_at_protocol_version_8() -> void:
# All regenerated fixtures should be at v8
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
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:
@@ -282,10 +282,10 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
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()
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 ----------------------------------
+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"]
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.execute(
"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)}")
if 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()
if incomplete:
print("Carry-over candidates (incomplete):")
print("Carry-over candidates (never started):")
format_ticket_table(incomplete)
print()
+7 -6
View File
@@ -14,7 +14,7 @@ Usage:
ticket sprint assign <id> <sprint_id>
ticket deps <id>
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 children <id>
ticket count [--status S]
@@ -256,9 +256,9 @@ def cmd_search(conn, keyword):
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:
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
ticket_type = positional[0]
title = " ".join(positional[1:])
@@ -266,9 +266,10 @@ def cmd_create(conn, args):
priority = flags.get("priority", "medium")
decision_ref = flags.get("decision")
team = flags.get("team")
description = flags.get("description")
conn.execute(
"INSERT INTO tickets (type, title, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?)",
(ticket_type, title, parent_id, priority, decision_ref, team))
"INSERT INTO tickets (type, title, description, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?, ?)",
(ticket_type, title, description, parent_id, priority, decision_ref, team))
conn.commit()
last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"]
out({"ok": True, "id": last_id, "title": title})
@@ -330,7 +331,7 @@ Usage:
ticket sprint assign <id> <sprint> Assign ticket to sprint
ticket deps <id> Show ticket dependencies
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 children <id> List children of a ticket
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).
### 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
```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
```
### 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:
- **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.
+16 -16
View File
@@ -5,20 +5,20 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
## Generation Approach
- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`.
- **Monologue chimes:** 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
| 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-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-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-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-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-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-007 | `sfx_weapon_aim_lock.ogg` | draft | UI Sounds | SAO | 300-500ms | Sharp targeting lock-on confirmation for weapon aim. Clinical/dangerous, harder than general entity lock. Descending digital sweep with metallic precision click. | S8 #440 |
| UI-008 | `sfx_stance_change.ogg` | draft | UI Sounds | SAO | 200-400ms | Soft mechanical mode-switch click for stance toggle (Walk/Sprint/Careful/Crouch). Subtle digital detent, nearly subliminal. Fires frequently — fatigue-safe. | S8 #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` | done | UI Sounds | SAO | 217ms | Rising tone when insert UI opens (radial menu, inventory, stance). Neural lattice powering up. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-003 | `fog_recognition.ogg` | done | UI Sounds | SAO | 400ms | Warm organic chime at onset of cognitive delay (D-060). Re-generated in S9 — original was silent. Organic prefix + Chimes template. | S7 #440, S9 #453 |
| UI-004 | `weapon_aim.ogg` | done | UI Sounds | SAO | 127ms | Harder click/lock for weapon aim state. Mechanical, deliberate. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template. Crystalline tone, neural lattice surfacing a thought. See design brief below. | S7 #440, S9 #453 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template (sharper/brighter variant). CFG 8. See design brief below. | S7 #440, S9 #453 |
| UI-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)
@@ -65,9 +65,9 @@ These are AudioBus parameter changes, documented in `docs/audio/dialogue-ambient
- **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 (PLACEHOLDER)
### UI-005 / UI-006: Monologue Chime Design Brief
**Target emotional register for Sprint 8 redo:**
**Target emotional register (achieved Sprint 9 #453):**
The monologue chimes are the two poles of augmented cognition made audible:
@@ -84,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."
**Placeholder spec (Sprint 7):**
- Normal: pure sine 1.2kHz, 600ms, gentle attack, natural decay. Conservative volume.
- Urgent: sine 1.2kHz + harmonic 2.4kHz, 400ms, sharper attack, 15% louder.
- Deliberately undersized — obviously temporary. Ugly placeholders get replaced.
**Sprint 9 generation spec:**
- 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: 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.
- 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.
+20
View File
@@ -950,6 +950,19 @@ dependencies = [
"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]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -975,6 +988,7 @@ dependencies = [
"rand_chacha",
"rmp-serde",
"serde",
"serde_json",
"serde_yaml",
"thiserror",
"tracing",
@@ -1373,3 +1387,9 @@ dependencies = [
"quote",
"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"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[features]
default = ["gauntlet"]
gauntlet = []
[dev-dependencies]
serde_json = "1"
+1
View File
@@ -9,6 +9,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod framing;
pub mod local;
pub mod tcp;
pub mod text_renderer;
pub mod types;
pub use types::*;
+6 -6
View File
@@ -46,9 +46,9 @@ impl TcpBridge {
// Set non-blocking so receive_inputs doesn't stall the game loop.
// read_framed handles WouldBlock by returning Ok(None).
stream.set_nonblocking(true).map_err(|e| {
BridgeError::Transport(format!("failed to set non-blocking: {}", e))
})?;
stream
.set_nonblocking(true)
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
// Clone stream for reader and writer
let reader_stream = stream.try_clone().map_err(|e| {
@@ -81,9 +81,9 @@ impl TcpBridge {
local_addr
);
stream.set_nonblocking(true).map_err(|e| {
BridgeError::Transport(format!("failed to set non-blocking: {}", e))
})?;
stream
.set_nonblocking(true)
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
let reader_stream = stream.try_clone().map_err(|e| {
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
+411
View File
@@ -0,0 +1,411 @@
// Text renderer for ObserverSnapshot — structured human-readable output.
// Library code callable by test-client crate and server integration tests.
// The server binary never references this module.
use std::fmt::Write;
use crate::bridge::types::*;
use crate::knowledge::types::{EntityVisibility, RelationshipState};
/// Format an ObserverSnapshot as structured text for human verification.
///
/// Output format matches the test-client --text specification:
/// header, game time, room, entities (sorted by distance), pending
/// recognitions, tiles, interactions, inventory, monologue, dialogue.
pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
let mut out = String::with_capacity(2048);
// Find player entity for position reference
let player = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Player));
let (px, py) = player.map(|p| (p.x as i32, p.y as i32)).unwrap_or((-1, -1));
// Header
writeln!(
out,
"=== Tick {} | Player ({},{}) facing {:?} | Stance: {:?} | TickRate: {:?} ===",
snapshot.tick,
px,
py,
snapshot.player_facing,
snapshot.player_stance,
snapshot.game_time.tick_rate,
)
.ok();
// Game time
let hours = (snapshot.game_time.time_of_day / 60) % 24;
let minutes = snapshot.game_time.time_of_day % 60;
writeln!(
out,
"Game time: Day {}, {:02}:{:02} ({:?})",
snapshot.game_time.day, hours, minutes, snapshot.game_time.day_phase,
)
.ok();
// Room name — stub until Gauntlet room constants are implemented (Sprint 9)
writeln!(out, "Room: (unknown)").ok();
// Non-player entities sorted by distance then entity_id
let mut entities: Vec<&VisibleEntity> = snapshot
.entities
.iter()
.filter(|e| !matches!(e.kind, EntityKind::Player))
.collect();
entities.sort_by_key(|e| {
let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs();
(dist, e.entity_id)
});
if !entities.is_empty() {
writeln!(out, "Entities ({}):", entities.len()).ok();
for e in &entities {
let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs();
writeln!(
out,
" {}:{:<8} ({},{}) {:<8} rel:{:<16} vis:{:<12} d={}",
kind_label(e.kind),
e.entity_id,
e.x as i32,
e.y as i32,
sector_label(e.visibility),
relationship_label(e.relationship),
observation_label(&e.observation),
dist,
)
.ok();
}
}
// Pending recognitions
if !snapshot.pending_recognitions.is_empty() {
write!(
out,
"Pending recognitions: {}",
snapshot.pending_recognitions.len()
)
.ok();
for pr in &snapshot.pending_recognitions {
let elapsed = pr.total_delay_ticks - pr.remaining_ticks;
write!(
out,
" [npc:{} at ({},{}) {}/{} ticks]",
pr.entity_id, pr.x as i32, pr.y as i32, elapsed, pr.total_delay_ticks,
)
.ok();
}
writeln!(out).ok();
}
// Tiles
writeln!(out, "Tiles: {} visible", snapshot.visible_tiles.len()).ok();
// Interactions
if !snapshot.nearby_interactions.is_empty() {
writeln!(
out,
"Interactions ({}):",
snapshot.nearby_interactions.len()
)
.ok();
for ni in &snapshot.nearby_interactions {
let verbs: Vec<String> = ni
.verbs
.iter()
.map(|v| format!("{}({})", v.label, v.priority))
.collect();
writeln!(
out,
" {}:{} [{}] distance={}",
kind_label(ni.entity_type),
ni.entity_id,
verbs.join(", "),
ni.distance,
)
.ok();
}
}
// Inventory
if !snapshot.player_inventory.is_empty() {
let slots: Vec<String> = snapshot
.player_inventory
.iter()
.map(|item| format!("item:{}(slot-{})", item.item_id, item.slot))
.collect();
writeln!(
out,
"Inventory: {}/9 [{}]",
snapshot.player_inventory.len(),
slots.join(", "),
)
.ok();
}
// Monologue
if let Some(ref mono) = snapshot.current_monologue {
writeln!(out, "Monologue: \"{}\"", mono.text).ok();
}
// Dialogue response
if let Some(ref dialogue) = snapshot.dialogue_response {
writeln!(
out,
"Dialogue: [npc:{}] \"{}\"",
dialogue.speaker_entity_id, dialogue.text
)
.ok();
}
writeln!(out, "===").ok();
out
}
fn kind_label(kind: EntityKind) -> &'static str {
match kind {
EntityKind::Player => "player",
EntityKind::Npc => "npc",
EntityKind::Object => "obj",
EntityKind::Terrain => "terrain",
}
}
fn sector_label(sector: VisibilitySector) -> &'static str {
match sector {
VisibilitySector::Forward => "Forward",
VisibilitySector::Peripheral => "Periph",
}
}
fn relationship_label(rel: RelationshipState) -> &'static str {
match rel {
RelationshipState::Unknown => "Unknown",
RelationshipState::Known => "Known",
RelationshipState::Friendly => "Friendly",
RelationshipState::PersonOfInterest => "POI",
RelationshipState::Hostile => "Hostile",
}
}
fn observation_label(obs: &EntityVisibility) -> &'static str {
match obs {
EntityVisibility::Visible => "Visible",
EntityVisibility::Remembered { .. } => "Remembered",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::time::{DayPhase, TickRate};
fn make_snapshot() -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 0,
time_of_day: 252,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::East,
player_stance: MovementStance::Walk,
player_inventory: vec![],
entities: vec![
VisibleEntity {
entity_id: 1,
x: 15.0,
y: 10.0,
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
},
VisibleEntity {
entity_id: 100,
x: 18.0,
y: 10.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Known,
observation: EntityVisibility::Visible,
},
VisibleEntity {
entity_id: 200,
x: 16.0,
y: 9.0,
z: 0,
kind: EntityKind::Object,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
},
],
visible_tiles: vec![VisibleTile {
x: 15,
y: 10,
z: 0,
visibility: VisibilitySector::Forward,
tile_kind: TileKind::Floor,
}],
nearby_interactions: vec![NearbyInteraction {
entity_id: 100,
entity_type: EntityKind::Npc,
distance: 3,
verbs: vec![
VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
},
VerbOption {
kind: VerbKind::ExamineNpc,
label: "ExamineNpc".into(),
priority: 2,
available: true,
},
],
object_type: None,
contradicted: false,
}],
current_monologue: Some(MonologueEvent {
id: "mono_test_1".into(),
text: "Something about this manifest doesn't add up.".into(),
duration_seconds: 3.0,
}),
pending_recognitions: vec![],
dialogue_response: None,
}
}
#[test]
fn header_contains_tick_and_position() {
let text = format_snapshot_text(&make_snapshot());
assert!(text.contains("Tick 42"));
assert!(text.contains("Player (15,10)"));
assert!(text.contains("facing East"));
assert!(text.contains("Stance: Walk"));
}
#[test]
fn game_time_formatted() {
let text = format_snapshot_text(&make_snapshot());
assert!(text.contains("Day 0, 04:12 (Morning)"));
}
#[test]
fn entities_sorted_by_distance() {
let text = format_snapshot_text(&make_snapshot());
// obj:200 at (16,9) is distance 2 from player (15,10)
// npc:100 at (18,10) is distance 3 from player (15,10)
let obj_pos = text.find("obj:200").unwrap();
let npc_pos = text.find("npc:100").unwrap();
assert!(
obj_pos < npc_pos,
"obj:200 (d=2) should appear before npc:100 (d=3)"
);
}
#[test]
fn player_excluded_from_entity_list() {
let text = format_snapshot_text(&make_snapshot());
assert!(text.contains("Entities (2):"));
assert!(!text.contains("player:1"));
}
#[test]
fn interactions_rendered() {
let text = format_snapshot_text(&make_snapshot());
assert!(text.contains("Interactions (1):"));
assert!(text.contains("Talk(1)"));
assert!(text.contains("distance=3"));
}
#[test]
fn monologue_rendered() {
let text = format_snapshot_text(&make_snapshot());
assert!(text.contains("Monologue: \"Something about this manifest doesn't add up.\""));
}
#[test]
fn dialogue_rendered_when_present() {
let mut snap = make_snapshot();
snap.dialogue_response = Some(DialogueResponseEvent {
line_id: "line_test".into(),
text: "Welcome to the docks.".into(),
speaker_entity_id: 100,
});
let text = format_snapshot_text(&snap);
assert!(text.contains("Dialogue: [npc:100] \"Welcome to the docks.\""));
}
#[test]
fn pending_recognitions_rendered() {
let mut snap = make_snapshot();
snap.pending_recognitions = vec![PendingRecognitionWire {
entity_id: 104,
x: 19.0,
y: 12.0,
z: 0,
remaining_ticks: 5,
total_delay_ticks: 8,
}];
let text = format_snapshot_text(&snap);
assert!(text.contains("Pending recognitions: 1"));
assert!(text.contains("npc:104 at (19,12) 3/8 ticks"));
}
#[test]
fn inventory_rendered() {
let mut snap = make_snapshot();
snap.player_inventory = vec![
InventoryItem {
item_id: 300,
name: "Manifest".into(),
slot: 0,
},
InventoryItem {
item_id: 301,
name: "Keycard".into(),
slot: 3,
},
];
let text = format_snapshot_text(&snap);
assert!(text.contains("Inventory: 2/9"));
assert!(text.contains("item:300(slot-0)"));
assert!(text.contains("item:301(slot-3)"));
}
#[test]
fn empty_snapshot_no_panic() {
let snap = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::Walk,
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
assert!(text.contains("Player (-1,-1)"));
assert!(text.contains("Tiles: 0 visible"));
}
}
+5 -1
View File
@@ -87,7 +87,11 @@ const MAX_WALK_DEPTH: usize = 100;
/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops.
fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap<PathBuf, SystemTime>, depth: usize) {
if depth >= MAX_WALK_DEPTH {
tracing::warn!("walk_yaml: max depth {} reached at {:?}, stopping", MAX_WALK_DEPTH, dir);
tracing::warn!(
"walk_yaml: max depth {} reached at {:?}, stopping",
MAX_WALK_DEPTH,
dir
);
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
+4 -3
View File
@@ -197,9 +197,10 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Register in EntityRegistry for StableId mapping
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
world
.entity_mut(entity)
.insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone())));
world.entity_mut(entity).insert((
StableEntityId(stable_id),
ContentSlug(profile.canonical_id.clone()),
));
result
.npc_ids
+38 -5
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.
pub fn len(&self) -> usize {
self.by_entity.len()
@@ -214,11 +227,7 @@ mod tests {
id_first, id_second,
"Re-registration must assign a new StableId"
);
assert_eq!(
id_second,
StableId(1),
"Counter advances monotonically"
);
assert_eq!(id_second, StableId(1), "Counter advances monotonically");
assert_eq!(registry.len(), 1);
// New mapping is bidirectionally correct
@@ -233,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]
fn unregister_unknown_entity_is_noop() {
// #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 simulation;
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;
+15 -3
View File
@@ -114,8 +114,18 @@ fn main() {
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
// Gauntlet content loader is future scope — proof room for all modes.
setup_proof_room(&mut app);
// Gauntlet test world for --test-mode, proof room for normal mode.
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!(
"Simulation initialized (seed={}, test_mode={})",
@@ -162,7 +172,9 @@ fn setup_proof_room(app: &mut App) {
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::movement::{
PlayerCharacter, TilePosition, WalkabilityMap,
};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
+2 -4
View File
@@ -26,10 +26,8 @@ impl Plugin for PerceptionPlugin {
.add_systems(
Update,
(
anomaly::clear_anomaly_markers
.before(anomaly::detect_anomalies),
anomaly::detect_anomalies
.before(observation::emit_observation_events),
anomaly::clear_anomaly_markers.before(anomaly::detect_anomalies),
anomaly::detect_anomalies.before(observation::emit_observation_events),
cognitive_delay::process_cognitive_delay
.after(observation::emit_observation_events)
.before(crate::knowledge::events::process_knowledge_events),
+1 -1
View File
@@ -15,9 +15,9 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
use crate::perception::cognitive_delay::CognitiveDelay;
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::stance::Stance;
+5 -1
View File
@@ -1948,7 +1948,11 @@ fn equidistant_npcs_produce_stable_snapshot_ordering() {
.map(|e| e.entity_id)
.collect();
assert_eq!(npc_ids.len(), 3, "all three equidistant NPCs should be visible");
assert_eq!(
npc_ids.len(),
3,
"all three equidistant NPCs should be visible"
);
// Entity IDs must be in strictly ascending order (Fix B sort guarantee)
for i in 1..npc_ids.len() {
+42 -20
View File
@@ -19,7 +19,9 @@ use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
use crate::content::line_pool::{AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier};
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::movement::PlayerCharacter;
@@ -219,7 +221,11 @@ pub fn derive_situations(
/// - Topic match: +2 per matching topic
///
/// Returns 0 only for lines on cooldown (caller handles).
pub fn score_line(line: &IndexedDialogueLine, npc_mood: Option<Mood>, active_topics: &[Topic]) -> u32 {
pub fn score_line(
line: &IndexedDialogueLine,
npc_mood: Option<Mood>,
active_topics: &[Topic],
) -> u32 {
let mut score: u32 = 1; // Base score — no line is excluded by Layer 4
// Mood match
@@ -318,8 +324,14 @@ pub fn process_talk_interaction(
return;
};
let Ok((player_entity, observer_kg, talk_request, mut response_buffer, mut cooldown, active_dialogue_opt)) =
player_query.single_mut()
let Ok((
player_entity,
observer_kg,
talk_request,
mut response_buffer,
mut cooldown,
active_dialogue_opt,
)) = player_query.single_mut()
else {
return;
};
@@ -784,9 +796,14 @@ mod tests {
let mut match_count = 0;
for seed in 0..100 {
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed);
if let Some(line) =
select_dialogue_line(&candidates, Some(Mood::Worried), &[], &cooldown, 0, &mut rng)
{
if let Some(line) = select_dialogue_line(
&candidates,
Some(Mood::Worried),
&[],
&cooldown,
0,
&mut rng,
) {
if line.id == "matched" {
match_count += 1;
}
@@ -869,9 +886,10 @@ mod tests {
role: "dock-worker".to_string(),
lines,
};
index
.dialogue
.insert(("the-terminal".to_string(), "dock-worker".to_string()), pool);
index.dialogue.insert(
("the-terminal".to_string(), "dock-worker".to_string()),
pool,
);
index
}
@@ -1009,10 +1027,11 @@ mod tests {
let mut seen_ids: Vec<String> = Vec::new();
for seed in 0..20 {
// Reset for each iteration
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world
.entity_mut(player)
.insert(TalkRequest { target: npc });
.get_mut::<DialogueResponseBuffer>(player)
.unwrap()
.response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
world.insert_resource(SimRng::new(seed));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -1020,7 +1039,11 @@ mod tests {
schedule.run(&mut world);
world.flush();
if let Some(resp) = &world.get::<DialogueResponseBuffer>(player).unwrap().response {
if let Some(resp) = &world
.get::<DialogueResponseBuffer>(player)
.unwrap()
.response
{
if !seen_ids.contains(&resp.line_id) {
seen_ids.push(resp.line_id.clone());
}
@@ -1046,9 +1069,7 @@ mod tests {
world.insert_resource(LinePoolIndexResource(index));
// NPC without DialogueProfile
let npc = world
.spawn((Npc, TilePosition::new(5, 5, 0)))
.id();
let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
@@ -1142,10 +1163,11 @@ mod tests {
);
// Second talk — same tick, line on cooldown
world.get_mut::<DialogueResponseBuffer>(player).unwrap().response = None;
world
.entity_mut(player)
.insert(TalkRequest { target: npc });
.get_mut::<DialogueResponseBuffer>(player)
.unwrap()
.response = None;
world.entity_mut(player).insert(TalkRequest { target: npc });
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
schedule2.add_systems(process_talk_interaction);
+310 -17
View File
@@ -11,6 +11,7 @@ use crate::simulation::inventory::{
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
use bevy_ecs::prelude::*;
use std::collections::VecDeque;
@@ -91,6 +92,8 @@ pub fn process_player_input(
>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
all_positions: Query<&TilePosition>,
reset_triggers: Query<&RoomResetTrigger>,
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
) {
let current_tick = time.tick;
let paused = time.paused();
@@ -102,12 +105,7 @@ pub fn process_player_input(
for input in inputs {
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
// Only Pause/Unpause are processed — everything else is discarded.
if paused
&& !matches!(
input.action,
PlayerAction::Pause | PlayerAction::Unpause
)
{
if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) {
continue;
}
match input.action {
@@ -190,14 +188,30 @@ pub fn process_player_input(
handle_place(&mut commands, &registry, &player_query, target_entity_id);
}
Some("Talk") => {
handle_talk(&mut commands, &registry, &player_query, &all_positions, target_entity_id);
handle_talk(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Reset") => {
handle_reset(
&mut commands,
&registry,
&reset_triggers,
&mut room_snapshots,
target_entity_id,
current_tick,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only",
target_entity_id,
verb,
);
"Interact: target={:?}, verb={:?} — logged only",
target_entity_id,
verb,
);
}
},
PlayerAction::WalkAway => {
@@ -363,7 +377,9 @@ fn handle_talk(
// Server-side range check: reject Talk if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
@@ -435,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)]
mod tests {
use super::*;
@@ -1312,11 +1389,7 @@ mod tests {
);
let mut query = world.query::<&Stance>();
let stance = query.single(&world).unwrap();
assert_eq!(
stance.0,
MovementStance::Walk,
"Stance unchanged in batch"
);
assert_eq!(stance.0, MovementStance::Walk, "Stance unchanged in batch");
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
@@ -1397,4 +1470,224 @@ mod tests {
schedule.add_systems(process_player_input);
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
}
}
+19 -17
View File
@@ -39,18 +39,12 @@ pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
/// Fire DURING cognitive delay (when grey blob appears). Future: move to
/// content pools with trigger="observe_anomaly" + character match.
const RECOGNITION_LINES: &[(&str, &str)] = &[
(
"recognition_01",
"Wait \u{2014} I know that walk.",
),
("recognition_01", "Wait \u{2014} I know that walk."),
(
"recognition_02",
"Those footsteps... I've heard that pattern before.",
),
(
"recognition_03",
"Something about that silhouette...",
),
("recognition_03", "Something about that silhouette..."),
];
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
@@ -258,9 +252,9 @@ pub fn trigger_recognition_monologue(
let pending = cognitive_delay.pending_mut();
let target_idx = {
// First pass: anomalous + unfired
let anomaly_idx = pending.iter().position(|p| {
!p.monologue_fired && anomaly_markers.get(p.target).is_ok()
});
let anomaly_idx = pending
.iter()
.position(|p| !p.monologue_fired && anomaly_markers.get(p.target).is_ok());
if let Some(idx) = anomaly_idx {
Some(idx)
} else {
@@ -872,10 +866,10 @@ mod tests {
// trigger_recognition_monologue tests (#451, D-060)
// -----------------------------------------------------------------------
use crate::knowledge::types::StableId;
use crate::perception::cognitive_delay::{
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
};
use crate::knowledge::types::StableId;
fn setup_recognition_world() -> World {
let mut world = World::new();
@@ -957,7 +951,12 @@ mod tests {
// First tick: fires
schedule.run(&mut world);
assert!(
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_some(),
world
.query::<&MonologueBuffer>()
.single(&world)
.unwrap()
.event
.is_some(),
"first tick should fire"
);
@@ -967,7 +966,12 @@ mod tests {
// Second tick: should NOT fire (monologue_fired = true)
schedule.run(&mut world);
assert!(
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_none(),
world
.query::<&MonologueBuffer>()
.single(&world)
.unwrap()
.event
.is_none(),
"second tick should not fire (already fired for this recognition)"
);
}
@@ -1053,9 +1057,7 @@ mod tests {
let mut world = setup_recognition_world();
let normal_target = world.spawn_empty().id();
let anomalous_target = world
.spawn(crate::perception::anomaly::AnomalyMarker)
.id();
let anomalous_target = world.spawn(crate::perception::anomaly::AnomalyMarker).id();
let mut cd = CognitiveDelay::default();
// Normal entity added first
+4 -2
View File
@@ -917,12 +917,14 @@ mod tests {
// Entity with lower bits processes first and claims the target
assert_eq!(
pos_lower, target,
pos_lower,
target,
"entity with lower Entity::to_bits() ({}) should win the tile",
lower.to_bits()
);
assert_eq!(
pos_higher, higher_origin,
pos_higher,
higher_origin,
"entity with higher Entity::to_bits() ({}) should stay at origin",
higher.to_bits()
);
+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
);
}
}
}

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