Compare commits

...
44 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 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
170 changed files with 18693 additions and 105 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
+44
View File
@@ -6,7 +6,46 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.9] — 2026-02-18
### Fixed
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
### Added
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
### Changed
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
### Added
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
- Domain-action naming convention for skills documented in create-skill guide
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
- Reset plate StableId verification in `stable_id_ranges_match_spec`
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
### Changed
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
- `room_at()` documented with z-range and corridor overlap assumptions
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
### Removed
- Dead `RoomMember` component from reset.rs (defined but never used)
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
@@ -15,6 +54,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- 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
+64 -8
View File
@@ -5,7 +5,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
# --- Configuration ---
@@ -39,6 +40,10 @@ help:
@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)"
@@ -65,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
@@ -79,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 ---
@@ -90,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 &
@@ -121,6 +128,33 @@ fixtures-client:
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/
@@ -158,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 ""; \
@@ -174,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)
@@ -183,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) ---
@@ -224,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
@@ -233,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()
@@ -0,0 +1 @@
uid://bkbfcalfo1kk8
@@ -0,0 +1 @@
uid://cw154tgjdyhe1
@@ -0,0 +1 @@
uid://dje4vilxh5xxu
+1
View File
@@ -0,0 +1 @@
uid://bcc1wwpk3moh1
+33 -4
View File
@@ -12,6 +12,7 @@ extends SceneTree
var _Msgpack: GDScript
var _count := 0
var _errors := 0
var _output_dir: String
@@ -22,27 +23,48 @@ func _init():
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
@@ -90,10 +112,11 @@ func _encode_inputs(inputs: Array) -> PackedByteArray:
func _generate_inputs() -> void:
# Unit variants: movement directions (tick=100)
_write_fixture("input_move_north",
_encode_input(100, "MoveNorth"))
for dir_name in ["MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest"]:
# 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))
@@ -108,8 +131,14 @@ func _generate_inputs() -> void:
# 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:
+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
+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
+1
View File
@@ -0,0 +1 @@
uid://br7kv0dyk3w2k
+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"""
+37
View File
@@ -82,6 +82,30 @@ The bidirectional protocol is validated by two sets of committed fixtures:
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
@@ -143,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"
+37
View File
@@ -69,6 +69,19 @@ impl EntityRegistry {
}
}
/// Advance the next StableId counter to `target`.
/// Used to reserve StableId ranges for entities not yet spawned
/// (e.g., Gauntlet rooms built in later sprints).
/// Panics if `target` is less than the current next_id.
pub fn reserve_up_to(&mut self, target: u64) {
assert!(
target >= self.next_id,
"cannot reserve backwards: next_id={}, target={}",
self.next_id, target
);
self.next_id = target;
}
/// Number of registered entities.
pub fn len(&self) -> usize {
self.by_entity.len()
@@ -229,6 +242,30 @@ mod tests {
);
}
#[test]
fn reserve_up_to_advances_counter() {
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let e1 = world.spawn_empty().id();
let id1 = registry.register(e1);
assert_eq!(id1, StableId(0));
// Reserve through 5 (skip IDs 1-4)
registry.reserve_up_to(5);
let e2 = world.spawn_empty().id();
let id2 = registry.register(e2);
assert_eq!(id2, StableId(5), "next ID after reserve should be 5");
}
#[test]
#[should_panic(expected = "cannot reserve backwards")]
fn reserve_up_to_panics_on_backwards() {
let mut registry = EntityRegistry::new(10);
registry.reserve_up_to(5);
}
#[test]
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;
+12 -2
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={})",
+294
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();
@@ -193,6 +196,16 @@ pub fn process_player_input(
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",
@@ -438,6 +451,67 @@ fn handle_place(
);
}
/// Handle Reset verb: restore a room's entities to their initial positions.
/// Target entity must have a RoomResetTrigger component. Respects debounce.
fn handle_reset(
commands: &mut Commands,
registry: &EntityRegistry,
reset_triggers: &Query<&RoomResetTrigger>,
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
target_entity_id: Option<u64>,
current_tick: u64,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Reset verb without target_entity_id");
return;
};
let Some(snapshots) = room_snapshots.as_mut() else {
tracing::warn!("Reset verb but RoomSnapshots resource not available");
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Reset: target entity not in registry");
return;
};
let Ok(trigger) = reset_triggers.get(target_entity) else {
tracing::warn!(target_id, "Reset: target is not a reset trigger");
return;
};
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
tracing::info!(
room = trigger.room_name.as_str(),
"Reset: debounced or unknown room"
);
return;
};
let mut restored = 0;
for (entity, position, is_floor_item) in changes {
if is_floor_item {
commands
.entity(entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(position);
} else {
commands.entity(entity).insert(position);
}
restored += 1;
}
tracing::info!(
room = trigger.room_name.as_str(),
restored,
current_tick,
"Room reset executed via Reset verb"
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1396,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
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Gauntlet room and entity constants — single source of truth.
//!
//! All Gauntlet tests and room builders reference these constants instead
//! of magic numbers. Room geometry, observer positions, entity placements,
//! and StableId ranges are defined here.
//!
//! Canonical spawn order determines StableId assignment. Do NOT reorder
//! existing entries — append new rooms/entities at the end (additive-only).
//!
//! Specification source: gestalt-round3.md Section 7.
use crate::bridge::types::FacingDirection;
use crate::perception::vision_cone::Facing;
use crate::simulation::movement::TilePosition;
/// A Gauntlet room definition.
#[derive(Debug, Clone)]
pub struct GauntletRoom {
/// Room identifier string (e.g., "central_hub", "occlusion_corridor").
pub name: &'static str,
/// Top-left corner of the room (includes walls).
pub origin: TilePosition,
/// (width, height) in sim tiles (includes walls).
pub size: (i32, i32),
/// Player spawn position within this room (absolute coordinates).
pub spawn: TilePosition,
/// Golden file observer position (absolute coordinates).
pub observer: TilePosition,
/// Direction the observer faces for golden file snapshots.
pub observer_facing: Facing,
/// Reset plate location (if any). Only present in rooms with
/// corridor entrances (not the hub).
pub reset_plate: Option<TilePosition>,
}
/// A Gauntlet entity definition.
#[derive(Debug, Clone)]
pub struct GauntletEntity {
/// Entity name (e.g., "npc_guard_visible", "crate_01").
pub name: &'static str,
/// Which room this entity belongs to.
pub room: &'static str,
/// Absolute position in the Gauntlet map.
pub position: TilePosition,
/// Entity kind for classification.
pub kind: EntityKind,
/// StableId assigned to this entity.
pub stable_id: u64,
}
/// Entity classification for Gauntlet test entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityKind {
Sign,
Npc,
Object,
}
// ============================================================
// Room constants — canonical spawn order
// ============================================================
pub const HUB: GauntletRoom = GauntletRoom {
name: "central_hub",
origin: TilePosition { x: 38, y: 46, z: 0 },
size: (24, 24),
spawn: TilePosition { x: 50, y: 58, z: 0 },
observer: TilePosition { x: 50, y: 58, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: None,
};
pub const FOG_THEATER: GauntletRoom = GauntletRoom {
name: "fog_theater",
origin: TilePosition { x: 28, y: 2, z: 0 },
size: (44, 32),
spawn: TilePosition { x: 56, y: 18, z: 0 },
observer: TilePosition { x: 56, y: 18, z: 0 },
observer_facing: Facing(FacingDirection::South),
reset_plate: Some(TilePosition { x: 50, y: 34, z: 0 }),
};
pub const OCCLUSION_CORRIDOR: GauntletRoom = GauntletRoom {
name: "occlusion_corridor",
origin: TilePosition { x: 74, y: 48, z: 0 },
size: (42, 22),
spawn: TilePosition { x: 84, y: 58, z: 0 },
observer: TilePosition { x: 84, y: 58, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 73, y: 58, z: 0 }),
};
pub const INVENTORY_WAREHOUSE: GauntletRoom = GauntletRoom {
name: "inventory_warehouse",
origin: TilePosition { x: 2, y: 40, z: 0 },
size: (30, 28),
spawn: TilePosition { x: 17, y: 54, z: 0 },
observer: TilePosition { x: 17, y: 54, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 33, y: 58, z: 0 }),
};
pub const INTERACTION_GALLERY: GauntletRoom = GauntletRoom {
name: "interaction_gallery",
origin: TilePosition { x: 2, y: 82, z: 0 },
size: (24, 20),
spawn: TilePosition { x: 14, y: 92, z: 0 },
observer: TilePosition { x: 14, y: 92, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 14, y: 82, z: 0 }),
};
pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
name: "pause_chamber",
origin: TilePosition { x: 42, y: 78, z: 0 },
size: (16, 16),
spawn: TilePosition { x: 50, y: 86, z: 0 },
observer: TilePosition { x: 50, y: 86, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 50, y: 77, z: 0 }),
};
pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom {
name: "dialogue_room",
origin: TilePosition { x: 36, y: 104, z: 0 },
size: (28, 20),
spawn: TilePosition { x: 50, y: 114, z: 0 },
observer: TilePosition { x: 50, y: 114, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 50, y: 103, z: 0 }),
};
pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
name: "crowd_plaza",
origin: TilePosition { x: 80, y: 78, z: 0 },
size: (32, 32),
spawn: TilePosition { x: 96, y: 94, z: 0 },
observer: TilePosition { x: 96, y: 94, z: 0 },
observer_facing: Facing(FacingDirection::West),
reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }),
};
/// All rooms in canonical spawn order.
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
/// Do not reorder existing entries. Append new rooms at the end.
pub const ROOMS: &[GauntletRoom] = &[
HUB,
FOG_THEATER,
OCCLUSION_CORRIDOR,
INVENTORY_WAREHOUSE,
INTERACTION_GALLERY,
PAUSE_CHAMBER,
DIALOGUE_ROOM,
CROWD_PLAZA,
];
/// Look up which room a position falls in.
/// Returns the first room whose bounding box contains the position.
///
/// Assumptions:
/// - All rooms are at z=0 (single-floor Gauntlet). Positions on other
/// z-levels will never match.
/// - Room bounding boxes must not overlap (verified by `rooms_do_not_overlap`
/// test). Corridors are NOT rooms and intentionally fall outside all
/// bounding boxes — `room_at` returns None for corridor positions.
pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> {
ROOMS.iter().find(|r| {
pos.x >= r.origin.x
&& pos.x < r.origin.x + r.size.0
&& pos.y >= r.origin.y
&& pos.y < r.origin.y + r.size.1
&& pos.z == r.origin.z
})
}
// ============================================================
// StableId range constants
// ============================================================
/// Player entity always gets StableId 0.
pub const PLAYER_STABLE_ID: u64 = 0;
/// StableId ranges per room (start, end inclusive).
pub const HUB_STABLE_IDS: (u64, u64) = (1, 4);
pub const FOG_THEATER_STABLE_IDS: (u64, u64) = (5, 8);
pub const OCCLUSION_STABLE_IDS: (u64, u64) = (9, 12);
pub const INVENTORY_STABLE_IDS: (u64, u64) = (13, 23);
pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51);
/// Number of actively-spawned entities in the current Gauntlet build.
/// Derived from StableId ranges of built rooms + player + reset plates.
/// Reserved (unbuilt) rooms do not contribute entities.
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
+ (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize
+ (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_at_finds_hub() {
let pos = TilePosition { x: 50, y: 58, z: 0 };
let room = room_at(&pos).expect("Hub center should be in a room");
assert_eq!(room.name, "central_hub");
}
#[test]
fn room_at_finds_occlusion_corridor() {
let pos = TilePosition { x: 84, y: 58, z: 0 };
let room = room_at(&pos).expect("Occlusion observer should be in a room");
assert_eq!(room.name, "occlusion_corridor");
}
#[test]
fn room_at_finds_inventory_warehouse() {
let pos = TilePosition { x: 17, y: 54, z: 0 };
let room = room_at(&pos).expect("Inventory observer should be in a room");
assert_eq!(room.name, "inventory_warehouse");
}
#[test]
fn room_at_finds_pause_chamber() {
let pos = TilePosition { x: 50, y: 86, z: 0 };
let room = room_at(&pos).expect("Pause observer should be in a room");
assert_eq!(room.name, "pause_chamber");
}
#[test]
fn room_at_returns_none_for_corridor() {
// Point inside corridor-E (between Hub and Occlusion)
let pos = TilePosition { x: 66, y: 57, z: 0 };
assert!(room_at(&pos).is_none(), "Corridor should not be in any room");
}
#[test]
fn room_at_returns_none_for_outside_map() {
let pos = TilePosition { x: 200, y: 200, z: 0 };
assert!(room_at(&pos).is_none());
}
#[test]
fn all_rooms_in_correct_order() {
assert_eq!(ROOMS.len(), 8);
assert_eq!(ROOMS[0].name, "central_hub");
assert_eq!(ROOMS[1].name, "fog_theater");
assert_eq!(ROOMS[2].name, "occlusion_corridor");
assert_eq!(ROOMS[3].name, "inventory_warehouse");
assert_eq!(ROOMS[4].name, "interaction_gallery");
assert_eq!(ROOMS[5].name, "pause_chamber");
assert_eq!(ROOMS[6].name, "dialogue_room");
assert_eq!(ROOMS[7].name, "crowd_plaza");
}
#[test]
fn rooms_do_not_overlap() {
for (i, a) in ROOMS.iter().enumerate() {
for (j, b) in ROOMS.iter().enumerate() {
if i >= j {
continue;
}
let overlap_x = a.origin.x < b.origin.x + b.size.0
&& a.origin.x + a.size.0 > b.origin.x;
let overlap_y = a.origin.y < b.origin.y + b.size.1
&& a.origin.y + a.size.1 > b.origin.y;
assert!(
!(overlap_x && overlap_y),
"Rooms {} and {} overlap",
a.name, b.name
);
}
}
}
#[test]
fn observer_inside_room() {
for room in ROOMS {
let obs = &room.observer;
assert!(
obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0,
"Observer x={} outside room {} (origin.x={}, width={})",
obs.x, room.name, room.origin.x, room.size.0
);
assert!(
obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1,
"Observer y={} outside room {} (origin.y={}, height={})",
obs.y, room.name, room.origin.y, room.size.1
);
}
}
#[test]
fn stable_id_ranges_non_overlapping() {
let ranges = [
HUB_STABLE_IDS,
FOG_THEATER_STABLE_IDS,
OCCLUSION_STABLE_IDS,
INVENTORY_STABLE_IDS,
INTERACTION_GALLERY_STABLE_IDS,
PAUSE_CHAMBER_STABLE_IDS,
DIALOGUE_ROOM_STABLE_IDS,
CROWD_PLAZA_STABLE_IDS,
RESET_PLATE_STABLE_IDS,
];
for (i, a) in ranges.iter().enumerate() {
for (j, b) in ranges.iter().enumerate() {
if i >= j {
continue;
}
assert!(
a.1 < b.0 || b.1 < a.0,
"StableId ranges {} and {} overlap: {:?} vs {:?}",
i, j, a, b
);
}
}
}
}
+366
View File
@@ -0,0 +1,366 @@
//! Gauntlet test world — purpose-built rooms for systematic QA testing.
//!
//! NOT production content. This module provides deterministic room layouts
//! with precise entity placement for golden file testing, regression testing,
//! and manual QA sessions. Loaded instead of content/ when the server runs
//! the Gauntlet map.
//!
//! Module structure:
//! - `reset` — always compiled (used by simulation::input in production)
//! - `constants`, `rooms`, `setup_gauntlet` — gated behind "gauntlet" feature
//! (default-on; strip with --no-default-features for release builds)
//!
//! Architecture (per workshop-outcomes.md Section 4):
//! - Rooms are defined as Rust builder functions (hybrid YAML + Rust inject)
//! - WalkabilityMap covers the full Gauntlet bounds (0-116 x 0-124)
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8 (reserved, not yet built)
//! Occlusion Corridor: 9-12
//! Inventory Warehouse: 13-23
//! Interaction Gallery: 24-28 (reserved, not yet built)
//! Pause Chamber: 29
//! Dialogue Room: 30-33 (reserved, not yet built)
//! Crowd Plaza: 34-48 (reserved, not yet built)
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
#[cfg(feature = "gauntlet")]
pub mod constants;
pub mod reset;
#[cfg(feature = "gauntlet")]
pub mod rooms;
#[cfg(feature = "gauntlet")]
use bevy_app::prelude::*;
#[cfg(feature = "gauntlet")]
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
#[cfg(feature = "gauntlet")]
use crate::knowledge::KnowledgeGraph;
#[cfg(feature = "gauntlet")]
use crate::knowledge::types::StableId;
#[cfg(feature = "gauntlet")]
use crate::perception::cognitive_delay::CognitiveDelay;
#[cfg(feature = "gauntlet")]
use crate::perception::vision_cone::Facing;
#[cfg(feature = "gauntlet")]
use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer};
#[cfg(feature = "gauntlet")]
use crate::simulation::inventory::ItemName;
#[cfg(feature = "gauntlet")]
use crate::simulation::listening::ListeningFocus;
#[cfg(feature = "gauntlet")]
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
#[cfg(feature = "gauntlet")]
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
#[cfg(feature = "gauntlet")]
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
#[cfg(feature = "gauntlet")]
use reset::{RoomResetTrigger, RoomSnapshots};
#[cfg(feature = "gauntlet")]
/// Map bounds for the full Gauntlet world.
pub const MAP_WIDTH: i32 = 117;
#[cfg(feature = "gauntlet")]
pub const MAP_HEIGHT: i32 = 125;
/// Set up the Gauntlet test world.
///
/// Creates the full WalkabilityMap (blocked by default), carves room
/// interiors and corridors, spawns the player and all room entities
/// in canonical StableId order.
///
/// NOTE: This bypasses the normal startup system scheduler — entities are
/// spawned directly into the World rather than via startup systems. This
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
pub fn setup_gauntlet(app: &mut App) {
// Start with a fully blocked map, then carve rooms and corridors.
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
// Set up Occlusion Corridor walls (relative positions converted to absolute)
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
for x in 88..=96 {
for y in 54..=55 {
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
}
}
// South alcove walls: rel x=[4,8], y=[12,13]
for x in 78..=82 {
for y in 60..=61 {
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
}
}
app.insert_resource(walkability);
// Entity spawning in canonical StableId order.
// Player gets StableId 0, then entities by room in workshop order.
let mut registry = EntityRegistry::new(0);
// --- Player (StableId 0) ---
// Spawn at Hub center: absolute (50, 58)
let profile = MovementProfile::smuggler();
let player_pos = TilePosition::new(50, 58, 0);
let player = app
.world_mut()
.spawn((
PlayerCharacter,
player_pos,
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(player_pos),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
// --- Hub signs (StableId 1-4) ---
rooms::hub::spawn_entities(app, &mut registry);
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
registry.reserve_up_to(9);
// --- Occlusion Corridor (StableId 9-12) ---
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
// --- Inventory Warehouse (StableId 13-23) ---
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
registry.reserve_up_to(29);
// --- Pause Chamber (StableId 29) ---
rooms::pause_chamber::spawn_entities(app, &mut registry);
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
registry.reserve_up_to(49);
// --- Reset plates (StableId 49-51) ---
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
// Each plate triggers reset of its associated room.
let reset_plates: &[(&str, TilePosition)] = &[
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
];
for &(room_name, pos) in reset_plates {
let entity = app
.world_mut()
.spawn((
Interactable,
RoomResetTrigger {
room_name: room_name.to_string(),
},
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
for id in 9..=12 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("occlusion_corridor", entity, *pos, false);
}
}
}
// Inventory Warehouse entities (StableId 13-23): crates are floor items, NPC is not
for id in 13..=23 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
let is_floor_item = app.world().get::<ItemName>(entity).is_some();
snapshots.record("inventory_warehouse", entity, *pos, is_floor_item);
}
}
}
// Pause Chamber entity (StableId 29): NPC only
if let Some(entity) = registry.to_entity(&StableId(29)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("pause_chamber", entity, *pos, false);
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
#[cfg(feature = "gauntlet")]
/// Carve the walkable interior of a room.
/// Room has 2-tile-thick walls; interior starts 2 tiles inside each boundary.
fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
for y in (oy + 2)..(oy + h - 2) {
for x in (ox + 2)..(ox + w - 2) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(feature = "gauntlet")]
/// Carve a corridor as a fully walkable rectangle.
/// Corridors are 6 tiles wide with walls on both sides — we carve the
/// interior 2 tiles in from each edge (leaving 2-tile walkable center).
/// For narrow corridors (width=6), interior is 2 tiles wide.
fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
// Corridors are fully walkable rectangles (wall tiles are the
// surrounding room/map boundary). Carve interior with 1-tile margin.
for y in (oy + 1)..(oy + h - 1) {
for x in (ox + 1)..(ox + w - 1) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(all(test, feature = "gauntlet"))]
mod tests {
use super::*;
use crate::knowledge::registry::EntityRegistry;
#[test]
fn gauntlet_setup_creates_expected_entities() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
assert_eq!(
registry.len(),
constants::EXPECTED_ENTITY_COUNT,
"entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges"
);
}
#[test]
fn hub_center_is_walkable() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// Hub center at (50, 58) must be walkable
assert!(wm.can_move_to(&TilePosition::new(50, 58, 0)));
}
#[test]
fn occlusion_north_wall_blocks() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// North wall segment at absolute (90, 54) should be blocked
assert!(!wm.can_move_to(&TilePosition::new(90, 54, 0)));
// But the corridor interior at (90, 56) should be walkable
assert!(wm.can_move_to(&TilePosition::new(90, 56, 0)));
}
#[test]
fn corridor_connects_hub_to_occlusion() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// corridor-E center should be walkable
assert!(wm.can_move_to(&TilePosition::new(66, 57, 0)));
}
#[test]
fn stable_id_ranges_match_spec() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
// Player at StableId 0
use crate::knowledge::types::StableId;
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
// Hub signs at 1-4
for id in 1..=4 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
}
// Fog Theater 5-8 reserved (no entities)
for id in 5..=8 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
}
// Occlusion Corridor at 9-12
for id in 9..=12 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id);
}
// Inventory Warehouse at 13-23
for id in 13..=23 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
}
// Interaction Gallery 24-28 reserved
for id in 24..=28 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
}
// Pause Chamber at 29
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
// Reset plates at 49-51
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Reset plate at StableId {}",
id
);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Room reset trigger mechanism (#490).
//!
//! Restores a room's entities to their initial positions when the player
//! interacts with a reset plate. Used during Gauntlet QA sessions to
//! re-test a room without restarting the server.
//!
//! Components:
//! - `RoomResetTrigger` — marks an entity as a reset plate for a room
//!
//! Resource:
//! - `RoomSnapshots` — stores tick-0 entity positions per room
//!
//! Production path: `plan_reset` returns planned changes as a Vec, which
//! the input system applies via Commands (see simulation::input). This
//! avoids exclusive World access and is scheduler-friendly.
//!
//! Spec (workshop-outcomes.md Section 8):
//! - Trigger: Interact with reset plate entity (verb "Reset")
//! - Resets: entity positions, carried items from room returned to floor
//! - Does NOT reset: other rooms, player position, session timer, SimRng
//! - Debounce: 10-tick cooldown per room
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use crate::simulation::movement::TilePosition;
/// Debounce cooldown in ticks between resets of the same room.
pub const RESET_DEBOUNCE_TICKS: u64 = 10;
/// Marks an entity as a room reset trigger (reset plate).
/// The player interacts with this entity using the "Reset" verb
/// to restore the room to its initial state.
#[derive(Component, Debug, Clone)]
pub struct RoomResetTrigger {
pub room_name: String,
}
/// Snapshot of a single entity's initial position.
#[derive(Debug, Clone)]
struct EntitySnapshot {
entity: Entity,
position: TilePosition,
/// Whether this entity was a floor item (had TilePosition + ItemName at spawn).
is_floor_item: bool,
}
/// Per-room initial state snapshot.
#[derive(Debug, Clone, Default)]
struct RoomSnapshotData {
entities: Vec<EntitySnapshot>,
}
/// Resource holding initial entity state per room and debounce tracking.
#[derive(Resource, Debug, Default)]
pub struct RoomSnapshots {
snapshots: BTreeMap<String, RoomSnapshotData>,
last_reset_tick: BTreeMap<String, u64>,
}
impl RoomSnapshots {
/// Record the initial position of an entity in a room.
pub fn record(&mut self, room_name: &str, entity: Entity, position: TilePosition, is_floor_item: bool) {
self.snapshots
.entry(room_name.to_string())
.or_default()
.entities
.push(EntitySnapshot {
entity,
position,
is_floor_item,
});
}
/// Check if a reset is allowed (debounce check).
pub fn can_reset(&self, room_name: &str, current_tick: u64) -> bool {
match self.last_reset_tick.get(room_name) {
Some(&last) => current_tick >= last + RESET_DEBOUNCE_TICKS,
None => true,
}
}
/// Plan a room reset for use with Commands (system-friendly).
/// Returns the list of (entity, position, is_floor_item) changes to apply,
/// or None if debounced or unknown room. Updates debounce tracking.
///
/// This is the canonical production API — the input system applies the
/// returned changes via Commands to avoid exclusive World access.
pub fn plan_reset(
&mut self,
room_name: &str,
current_tick: u64,
) -> Option<Vec<(Entity, TilePosition, bool)>> {
if !self.can_reset(room_name, current_tick) {
return None;
}
let snapshot = self.snapshots.get(room_name)?;
let changes: Vec<_> = snapshot
.entities
.iter()
.map(|s| (s.entity, s.position, s.is_floor_item))
.collect();
self.last_reset_tick
.insert(room_name.to_string(), current_tick);
Some(changes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_reset_returns_correct_changes() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
let changes = snapshots.plan_reset("test_room", 0);
assert!(changes.is_some());
let changes = changes.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].0, entity);
assert_eq!(changes[0].1, TilePosition::new(10, 20, 0));
assert!(!changes[0].2); // not a floor item
}
#[test]
fn plan_reset_includes_floor_items() {
let mut world = World::new();
let item = world.spawn(TilePosition::new(5, 5, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("warehouse", item, TilePosition::new(5, 5, 0), true);
let changes = snapshots.plan_reset("warehouse", 0).unwrap();
assert_eq!(changes.len(), 1);
assert!(changes[0].2); // is a floor item
}
#[test]
fn plan_reset_debounces() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
// First reset at tick 0
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Second reset at tick 5 — should be debounced
assert!(snapshots.plan_reset("test_room", 5).is_none());
// Third reset at tick 10 — should succeed
assert!(snapshots.plan_reset("test_room", 10).is_some());
}
#[test]
fn plan_reset_debounce_exact_boundary() {
let mut world = World::new();
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
let mut snapshots = RoomSnapshots::default();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
// Reset at tick 0
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Tick 9: exactly one tick before debounce expires — must be rejected
assert!(
snapshots.plan_reset("test_room", 9).is_none(),
"tick 9 should be rejected (debounce is 10 ticks)"
);
// Tick 10: exact debounce boundary — must be accepted
assert!(
snapshots.plan_reset("test_room", 10).is_some(),
"tick 10 should be accepted (debounce elapsed)"
);
}
#[test]
fn plan_reset_unknown_room_returns_none() {
let mut snapshots = RoomSnapshots::default();
assert!(snapshots.plan_reset("nonexistent", 0).is_none());
}
#[test]
fn can_reset_fresh_room() {
let snapshots = RoomSnapshots::default();
assert!(snapshots.can_reset("any_room", 0));
}
#[test]
fn can_reset_after_debounce() {
let mut snapshots = RoomSnapshots::default();
snapshots
.last_reset_tick
.insert("room".to_string(), 100);
assert!(!snapshots.can_reset("room", 105));
assert!(snapshots.can_reset("room", 110));
assert!(snapshots.can_reset("room", 200));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Central Hub — Room 0 (24x24)
//!
//! Connector room with 4 directional sign markers. No test entities.
//! Hub spawn point: (12, 12) relative = (50, 58) absolute.
//!
//! Entities (StableId 1-4):
//! sign_north (50, 48) — "Fog Theater"
//! sign_east (60, 58) — "Occlusion Corridor"
//! sign_south (50, 68) — "Pause Chamber"
//! sign_west (40, 58) — "Inventory Warehouse"
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 38;
const ORIGIN_Y: i32 = 46;
/// Sign positions as relative offsets from room origin.
const SIGNS: &[(&str, i32, i32)] = &[
("sign_north", 12, 2), // North marker
("sign_east", 22, 12), // East marker
("sign_south", 12, 22), // South marker
("sign_west", 2, 12), // West marker
];
/// Spawn hub sign entities in canonical order (StableId 1-4).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(_name, rx, ry) in SIGNS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app.world_mut().spawn((Interactable, pos)).id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,88 @@
//! Inventory Warehouse — Room 3 (30x28)
//!
//! Tests D-065 (9-slot inventory), pickup/drop verbs, CarriedBy component.
//!
//! Layout: Open warehouse floor with 10 item crates in a grid pattern
//! and 1 NPC near the back wall for interaction testing while carrying items.
//!
//! Observer position: (15, 14) relative = (17, 54) absolute, facing East.
//!
//! Entities (StableId 13-23):
//! crate_01..crate_10 — Floor items (Container ObjectType)
//! npc_warehouse — Interaction target
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::inventory::ItemName;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 2;
const ORIGIN_Y: i32 = 40;
/// Item crate definitions: (name, relative_x, relative_y, item_name).
/// 10 crates for testing the 9-slot inventory limit.
const CRATES: &[(&str, i32, i32, &str)] = &[
("crate_01", 4, 4, "Keycard"),
("crate_02", 8, 4, "Manifest"),
("crate_03", 12, 4, "Datapad"),
("crate_04", 16, 4, "Toolkit"),
("crate_05", 4, 10, "Badge"),
("crate_06", 8, 10, "Medkit"),
("crate_07", 12, 10, "Ration"),
("crate_08", 16, 10, "Cable"),
("crate_09", 4, 16, "Seal"),
("crate_10", 8, 16, "Chip"),
];
/// Spawn Inventory Warehouse entities in canonical order (StableId 13-23).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
// Crates (StableId 13-22)
for &(_name, rx, ry, item_name) in CRATES {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Interactable,
ObjectType::Container,
ItemName(item_name.to_string()),
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// Warehouse NPC (StableId 23)
let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 14, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
npc_pos,
Want {
primary: WantKind::Wealth,
intensity: 5,
description: "Warehouse supervisor wants efficiency".to_string(),
},
Contentment { level: 10 },
ToleranceThreshold {
current_stress: 15,
threshold: 65,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
+9
View File
@@ -0,0 +1,9 @@
//! Gauntlet room builders.
//!
//! Each room module exports a `spawn_entities()` function that creates
//! entities in canonical order for deterministic StableId assignment.
pub mod hub;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;
@@ -0,0 +1,66 @@
//! Occlusion Corridor — Room 2 (42x22)
//!
//! Tests D-035 (symmetric shadowcasting), D-017 (perception modes),
//! D-015 (vision cone sectors).
//!
//! Layout: Long east-west corridor with perpendicular wall segments
//! creating visibility pockets. North alcove with hidden NPC,
//! south alcove with partially visible NPC.
//!
//! Observer position: (10, 10) relative = (84, 58) absolute, facing East.
//!
//! Entities (StableId 9-12):
//! npc_guard_visible (92, 58) — Clear LOS baseline
//! npc_hidden_wall (92, 52) — Behind north wall segment
//! npc_peripheral (80, 64) — South alcove, peripheral sector
//! npc_far_end (110, 58) — Far end of corridor, tests range
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 74;
const ORIGIN_Y: i32 = 48;
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
("npc_guard_visible", 18, 10, WantKind::Safety, 5),
("npc_hidden_wall", 18, 4, WantKind::Freedom, 3),
("npc_peripheral", 6, 16, WantKind::Knowledge, 6),
("npc_far_end", 36, 10, WantKind::Wealth, 4),
];
/// Spawn Occlusion Corridor entities in canonical order (StableId 9-12).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(name, rx, ry, want_kind, intensity) in NPCS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity,
description: format!("Occlusion Corridor test NPC: {}", name),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,51 @@
//! Pause Chamber — Room 5 (16x16)
//!
//! Tests D-031 (pause/unpause), Bug #3 regression (movement while paused).
//!
//! Layout: Minimal open room with a single NPC. No visual complexity —
//! this room is pure state machine testing.
//!
//! Observer position: (8, 8) relative = (50, 86) absolute, facing North.
//!
//! Entities (StableId 29):
//! npc_pause_target — Interaction target during pause (Talk should work)
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 42;
const ORIGIN_Y: i32 = 78;
/// Spawn Pause Chamber entities in canonical order (StableId 29).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
let pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 6, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 3,
description: "Wants a quiet posting".to_string(),
},
Contentment { level: 5 },
ToleranceThreshold {
current_stress: 10,
threshold: 40,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
+5
View File
@@ -3,6 +3,8 @@
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning.
//!
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -416,3 +418,6 @@ fn spawn_real_content_with_relationships_and_secrets() {
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// Runtime validation test (boot + tick 10 + snapshot) moved to
// server/tests/content_runtime.rs per architectural review.
+150
View File
@@ -0,0 +1,150 @@
//! Runtime validation: boot full plugin stack with real content, tick 10
//! times over TCP, assert valid ObserverSnapshot (#489).
//!
//! Separated from content_loading.rs (structural loading tests) per
//! architectural review — TCP runtime tests have different failure modes
//! and timeout characteristics.
use bevy_app::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::content::{ContentConfig, ContentPlugin};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::SimulationPlugin;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
/// Smoke test for production content. Boots the full plugin stack with real
/// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
/// Catches runtime panics from broken entity references, missing components,
/// or content schema issues that pass YAML validation but fail at tick time.
#[test]
fn content_runtime_boot_tick_10_snapshot() {
use std::io::{BufReader, BufWriter};
let root = content_root();
if !root.join("content.yaml").exists() {
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Server thread: full plugin stack with real content
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Spawn player with all required observer pipeline components
let profile = MovementProfile::smuggler();
let mut registry = EntityRegistry::new(0);
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
app.insert_resource(registry);
// Tick 10 times — any panic here means content has a runtime bug
for _ in 0..10 {
app.update();
}
});
// Client: connect with read timeout and receive 10 snapshots
let stream = TcpStream::connect(server_addr).expect("client connect");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
let mut last_snapshot = None;
for tick in 0..10 {
let payload = read_framed(&mut reader)
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
last_snapshot = Some(snapshot);
// Send empty input for next tick
let empty: Vec<PlayerInput> = vec![];
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
if write_framed(&mut writer, &input_payload).is_err() {
// Server may have shut down after tick 10 — that's fine
break;
}
}
drop(reader);
drop(writer);
// Server thread must not have panicked
server_handle
.join()
.expect("server thread panicked — content triggered a runtime error during tick processing");
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"snapshot protocol version mismatch"
);
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
// The player is at 16,16 — content NPCs are far away but the player entity itself
// should always be in the snapshot
assert!(
!snapshot.entities.is_empty(),
"snapshot should contain at least the player entity"
);
}
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
//! Golden file regression test (#485)
//!
//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot
//! to JSON, and compares against a committed golden file. Any deviation fails
//! the test with a field-level diff.
//!
//! To regenerate golden files after intentional changes:
//! UPDATE_GOLDEN=1 cargo test --test golden_suite
//!
//! Spec references: D-010 (deterministic simulation), D-030 (testability)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
use settled_reach_server::npc::{
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
Want, WantKind,
};
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
use bevy_app::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
const GOLDEN_DIR: &str = "tests/golden";
const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json";
const SEED: u64 = 42;
const NUM_TICKS: usize = 10;
/// Build a deterministic simulation app with the proof room.
/// Mirrors the setup in determinism.rs / main.rs.
fn build_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
{
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
}
let mut registry = EntityRegistry::new(0);
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
let npc1 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(16, 13, 0),
Want {
primary: WantKind::Wealth,
intensity: 6,
description: "Wants a bigger share of docking fees".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 13, 0),
activity: "Prep cargo bay".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 10, 0),
activity: "Unload freight".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 20, 0),
activity: "Drink at canteen".into(),
},
RoutineEntry {
phase: DayPhase::Night,
location: TilePosition::new(16, 13, 0),
activity: "Sleep in bunk".into(),
},
],
description: "Dock worker shift pattern".into(),
},
Contentment { level: 20 },
ToleranceThreshold {
current_stress: 30,
threshold: 70,
},
MovementSpeed::new(2),
))
.id();
let npc1_sid = registry.register(npc1);
let npc2 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(14, 18, 0),
Want {
primary: WantKind::Knowledge,
intensity: 8,
description: "Obsessed with pre-Collapse sensor arrays".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(14, 18, 0),
activity: "Calibrate instruments".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(22, 22, 0),
activity: "Field survey".into(),
},
],
description: "Field tech survey pattern".into(),
},
Contentment { level: 45 },
ToleranceThreshold {
current_stress: 10,
threshold: 60,
},
MovementSpeed::default(),
))
.id();
let npc2_sid = registry.register(npc2);
let npc3 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(18, 14, 0),
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Wants a quiet shift".into(),
},
Contentment { level: -5 },
ToleranceThreshold {
current_stress: 45,
threshold: 55,
},
))
.id();
let npc3_sid = registry.register(npc3);
{
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
rel_graph.set_relationship(
npc1_sid,
npc3_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 3,
history: vec![],
last_interaction_tick: 0,
},
);
rel_graph.set_relationship(
npc3_sid,
npc2_sid,
RelationshipEdge {
kind: RelationshipKind::Rival,
trust: -4,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.insert_resource(registry);
app
}
/// Standard 10-tick input sequence for golden file tests.
/// Matches the first 10 ticks of the determinism test in determinism.rs.
fn standard_inputs() -> Vec<Vec<PlayerInput>> {
vec![
// Tick 0: idle — baseline snapshot
vec![],
// Tick 1: move north
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
}],
// Tick 2: idle
vec![],
// Tick 3: move east
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveEast,
}],
// Tick 4: idle
vec![],
// Tick 5: move north
vec![PlayerInput {
tick: 5,
action: PlayerAction::MoveNorth,
}],
// Tick 6: stance toggle up
vec![PlayerInput {
tick: 6,
action: PlayerAction::ToggleStanceUp,
}],
// Tick 7: move north
vec![PlayerInput {
tick: 7,
action: PlayerAction::MoveNorth,
}],
// Tick 8: pause
vec![PlayerInput {
tick: 8,
action: PlayerAction::Pause,
}],
// Tick 9: move while paused (should be discarded)
vec![PlayerInput {
tick: 9,
action: PlayerAction::MoveNorth,
}],
]
}
/// Recursively sort all object keys for deterministic JSON output.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
/// Recursive JSON diff — reports all field-level differences with paths.
fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
all_keys.sort();
all_keys.dedup();
for key in all_keys {
let child = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match (e.get(key), a.get(key)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
(Value::Array(e), Value::Array(a)) => {
for i in 0..e.len().max(a.len()) {
let child = format!("{}[{}]", path, i);
match (e.get(i), a.get(i)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
_ => {
if expected != actual {
diffs.push(format!(
"{}: expected {}, got {}",
path, expected, actual
));
}
}
}
}
#[test]
fn proof_room_tick_10_matches_golden() {
let mut app = build_app(SEED);
let inputs = standard_inputs();
assert_eq!(inputs.len(), NUM_TICKS);
let mut last_snapshot: Option<ObserverSnapshot> = None;
for tick_inputs in &inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
last_snapshot = Some(snapshot.clone());
}
}
let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks");
// Serialize to sorted JSON for deterministic comparison
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
let actual_sorted = sort_json_keys(&actual_value);
let actual_json =
serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
// UPDATE_GOLDEN=1 mode: write the golden file and return
if std::env::var("UPDATE_GOLDEN").is_ok() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR);
std::fs::create_dir_all(&dir).expect("create golden directory");
std::fs::write(&golden_path, &actual_json).expect("write golden file");
eprintln!(
"Golden file written: {} ({} bytes)",
golden_path.display(),
actual_json.len()
);
return;
}
// Normal mode: compare against golden file
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
panic!(
"Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}",
golden_path.display(),
e
)
});
let golden_value: Value =
serde_json::from_str(&golden_json).expect("parse golden file as JSON");
let mut diffs = Vec::new();
diff_json("", &golden_value, &actual_sorted, &mut diffs);
if !diffs.is_empty() {
let mut msg = format!(
"Golden file mismatch ({} differences):\n",
diffs.len()
);
for diff in &diffs {
msg.push_str(&format!(" {}\n", diff));
}
msg.push_str(&format!(
"\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\
Golden file: {}",
golden_path.display()
));
panic!("{}", msg);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Layer 3 integration test: real subprocess IPC (D-030)
//!
//! Spawns the server binary as a child process with --test-mode --port 0,
//! parses the LISTENING:{port} handshake from stdout, connects via TCP,
//! sends a PlayerInput, and reads back an ObserverSnapshot.
//!
//! This is the highest-fidelity test layer: no mocks, no in-process bridge.
//! The server runs as a separate OS process, exactly as it does in production.
//!
//! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for the client to receive a snapshot after sending input.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
#[test]
fn server_subprocess_sends_snapshot_on_connect() {
// 1. Spawn server binary with --test-mode --port 0
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// 2. Parse LISTENING:{port} from stdout
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str
.parse::<u16>()
.unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e));
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(
Instant::now() < deadline,
"timed out waiting for LISTENING signal"
);
}
};
// 3. Connect to the server via TCP
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr)
.unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e));
stream
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
.expect("set read timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader"));
let mut writer = BufWriter::new(stream);
// 4. Send one PlayerInput (idle tick 0)
let inputs = vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}];
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
// 5. Read one ObserverSnapshot
let response = read_framed(&mut reader)
.expect("read snapshot frame")
.expect("server closed connection before sending snapshot");
let snapshot: ObserverSnapshot =
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
// 6. Assert protocol correctness (D-020)
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch: got {}, expected {}",
snapshot.version, PROTOCOL_VERSION
);
assert!(
snapshot.entities.len() > 0,
"snapshot should contain at least one entity (the player), got 0"
);
// The proof room has a player + NPCs. Verify the player entity exists.
let has_player = snapshot
.entities
.iter()
.any(|e| matches!(e.kind, EntityKind::Player));
assert!(has_player, "snapshot must contain a Player entity");
// 7. Clean up: drop connection so the server exits its game loop
drop(reader);
drop(writer);
// Wait for child to exit (with timeout)
let exit_deadline = Instant::now() + Duration::from_secs(5);
loop {
match child.try_wait() {
Ok(Some(_status)) => break,
Ok(None) => {
if Instant::now() > exit_deadline {
child.kill().ok();
child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
eprintln!("error waiting for server process: {}", e);
child.kill().ok();
break;
}
}
}
}
+11 -14
View File
@@ -1013,15 +1013,14 @@ fn malformed_input_in_batch_rejects_entire_batch() {
/// (runs client/tests/gen_client_fixtures.gd via Godot headless)
#[test]
fn gdscript_generated_fixtures_deserialize() {
// CWD is server/ when cargo test runs (Cargo sets it to the package root)
let fixture_dir = std::path::Path::new("tests/fixtures/gdscript");
if !fixture_dir.exists() {
eprintln!(
"SKIP: GDScript fixtures not found at {}. Run `make fixtures-client` to generate.",
fixture_dir.display()
);
return;
}
assert!(
fixture_dir.exists(),
"GDScript fixture directory not found at {}. Run `make fixtures-client` to generate.",
fixture_dir.display()
);
let mut count = 0;
for entry in fs::read_dir(&fixture_dir).expect("read gdscript fixture dir") {
@@ -1088,13 +1087,11 @@ fn gdscript_generated_fixtures_deserialize() {
count += 1;
}
if count == 0 {
eprintln!(
"SKIP: no .msgpack files found in {}. Run `make fixtures-client` to generate.",
fixture_dir.display()
);
return;
}
assert!(
count > 0,
"No .msgpack files found in {}. Run `make fixtures-client` to generate.",
fixture_dir.display()
);
eprintln!("Verified {} GDScript-generated fixtures", count);
}
+217 -21
View File
@@ -1,9 +1,16 @@
// Golden file comparison for test-client.
// Golden file comparison for test-client (#484).
// Compares the final ObserverSnapshot (as JSON) against a golden file.
// Reports field-by-field differences with JSON paths.
//
// Key properties:
// - Recursive diff with JSON path tracking (e.g. ".entities[0].x")
// - Object keys sorted before compare (BTreeMap semantics via serde_json)
// - Pretty-printed field-level diffs: path + expected + got
// - Used by #485 golden file test suite and --golden CLI flag
use serde_json::Value;
use settled_reach_server::bridge::types::ObserverSnapshot;
use std::collections::BTreeMap;
use std::path::Path;
/// Compare an ObserverSnapshot against a golden JSON file.
@@ -24,41 +31,89 @@ pub fn compare_golden(
let actual: Value = serde_json::to_value(snapshot)
.map_err(|e| format!("failed to serialize snapshot: {}", e))?;
Ok(diff_json_values(&golden, &actual))
}
/// Compare two JSON values and return all field-level differences.
/// Object keys are sorted (BTreeMap order) for deterministic comparison.
/// Returns an empty Vec when the values are equal.
pub fn diff_json_values(expected: &Value, actual: &Value) -> Vec<String> {
let mut diffs = Vec::new();
diff_values("", &golden, &actual, &mut diffs);
Ok(diffs)
diff_values("", expected, actual, &mut diffs);
diffs
}
/// Generate a golden file from an ObserverSnapshot.
/// Writes sorted, pretty-printed JSON for human readability and stable diffs.
pub fn generate_golden(golden_path: &Path, snapshot: &ObserverSnapshot) -> Result<(), String> {
let value: Value = serde_json::to_value(snapshot)
.map_err(|e| format!("failed to serialize snapshot: {}", e))?;
let sorted = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted)
.map_err(|e| format!("failed to format JSON: {}", e))?;
if let Some(parent) = golden_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"failed to create directory {}: {}",
parent.display(),
e
)
})?;
}
std::fs::write(golden_path, format!("{}\n", json)).map_err(|e| {
format!(
"failed to write golden file {}: {}",
golden_path.display(),
e
)
})
}
/// Recursively sort all object keys in a JSON value.
/// Ensures deterministic serialization regardless of insertion order.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
for key in e.keys() {
// Collect all keys from both sides, sorted for deterministic output
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
all_keys.sort();
all_keys.dedup();
for key in all_keys {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match a.get(key) {
Some(av) => diff_values(&child_path, &e[key], av, diffs),
None => diffs.push(format!(
match (e.get(key), a.get(key)) {
(Some(ev), Some(av)) => diff_values(&child_path, ev, av, diffs),
(Some(ev), None) => diffs.push(format!(
"{}: expected {}, got <missing>",
child_path,
format_value(&e[key])
format_value(ev)
)),
}
}
for key in a.keys() {
if !e.contains_key(key) {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
diffs.push(format!(
(None, Some(av)) => diffs.push(format!(
"{}: expected <missing>, got {}",
child_path,
format_value(&a[key])
));
format_value(av)
)),
(None, None) => unreachable!(),
}
}
}
@@ -82,6 +137,15 @@ fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<Str
}
}
}
(e, a) if std::mem::discriminant(e) != std::mem::discriminant(a) => {
// Type mismatch (e.g. number vs string)
diffs.push(format!(
"{}: type mismatch — expected {}, got {}",
path,
format_value(e),
format_value(a)
));
}
_ => {
if expected != actual {
diffs.push(format!(
@@ -99,6 +163,138 @@ fn format_value(v: &Value) -> String {
match v {
Value::String(s) => format!("{:?}", s),
Value::Null => "null".to_string(),
other => other.to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => format!("[...] ({} elements)", arr.len()),
Value::Object(map) => format!("{{...}} ({} keys)", map.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn identical_values_produce_no_diffs() {
let a = json!({"version": 8, "tick": 0, "entities": []});
let b = json!({"version": 8, "tick": 0, "entities": []});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn primitive_mismatch_reports_path() {
let a = json!({"version": 8, "tick": 0});
let b = json!({"version": 8, "tick": 5});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("expected 0"));
assert!(diffs[0].contains("got 5"));
}
#[test]
fn missing_key_in_actual() {
let expected = json!({"version": 8, "tick": 0});
let actual = json!({"version": 8});
let diffs = diff_json_values(&expected, &actual);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("<missing>"));
}
#[test]
fn extra_key_in_actual() {
let expected = json!({"version": 8});
let actual = json!({"version": 8, "tick": 0});
let diffs = diff_json_values(&expected, &actual);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("expected <missing>"));
}
#[test]
fn nested_object_diff() {
let a = json!({"game_time": {"day": 0, "time_of_day": 100}});
let b = json!({"game_time": {"day": 0, "time_of_day": 200}});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".game_time.time_of_day"));
}
#[test]
fn array_length_difference() {
let a = json!({"entities": [{"id": 1}, {"id": 2}]});
let b = json!({"entities": [{"id": 1}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".entities[1]"));
assert!(diffs[0].contains("<missing>"));
}
#[test]
fn array_element_diff() {
let a = json!({"entities": [{"x": 10.5, "y": 20.0}]});
let b = json!({"entities": [{"x": 10.5, "y": 25.0}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".entities[0].y"));
}
#[test]
fn type_mismatch_reports_clearly() {
let a = json!({"tick": 0});
let b = json!({"tick": "zero"});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains("type mismatch"));
}
#[test]
fn deeply_nested_path() {
let a = json!({"a": {"b": {"c": {"d": 1}}}});
let b = json!({"a": {"b": {"c": {"d": 2}}}});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0], ".a.b.c.d: expected 1, got 2");
}
#[test]
fn multiple_diffs_all_reported() {
let a = json!({"version": 7, "tick": 0, "entities": [{"id": 1}]});
let b = json!({"version": 8, "tick": 5, "entities": [{"id": 2}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 3);
}
#[test]
fn sort_json_keys_is_deterministic() {
let a = json!({"z": 1, "a": 2, "m": {"z": 3, "a": 4}});
let sorted = sort_json_keys(&a);
let output = serde_json::to_string(&sorted).unwrap();
assert_eq!(output, r#"{"a":2,"m":{"a":4,"z":3},"z":1}"#);
}
#[test]
fn empty_objects_match() {
let a = json!({});
let b = json!({});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn null_values_match() {
let a = json!({"field": null});
let b = json!({"field": null});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn null_vs_absent_reports_diff() {
let a = json!({"field": null});
let b = json!({});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".field"));
}
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Validate Gauntlet checklist YAML files against the checklist JSON schema.
Usage:
validate-checklist Validate all checklists + print summary
validate-checklist --check Schema validation only (for pre-PR chain)
Exit code 0 = all valid, 1 = validation errors found.
"""
import json
import sys
from pathlib import Path
import jsonschema
import yaml
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_PATH = ROOT / "content" / "_schema" / "checklist.schema.json"
GAUNTLET_DIR = ROOT / "content" / "gauntlet"
def load_schema():
if not SCHEMA_PATH.exists():
print(f"ERROR: Schema file not found at {_rel(SCHEMA_PATH)}")
print("Expected: content/_schema/checklist.schema.json")
sys.exit(1)
with open(SCHEMA_PATH) as f:
return json.load(f)
def find_checklists():
"""Find all checklist YAML files under content/gauntlet/."""
files = []
if not GAUNTLET_DIR.exists():
return files
for path in sorted(GAUNTLET_DIR.rglob("checklist.yaml")):
files.append(path)
cross = GAUNTLET_DIR / "cross_room_checks.yaml"
if cross.exists():
files.append(cross)
return files
def _rel(path: Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return str(path)
def validate_schema(files, schema):
"""Pass 1: JSON Schema validation. Returns (validated, errors)."""
errors = 0
validated = 0
for path in files:
rel = _rel(path)
try:
with open(path) as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
print(f"YAML ERROR: {rel}: {e}")
errors += 1
continue
if data is None:
print(f"EMPTY: {rel}")
errors += 1
continue
try:
jsonschema.validate(instance=data, schema=schema)
validated += 1
except jsonschema.ValidationError as e:
print(f"INVALID: {rel}")
print(f" Error: {e.message}")
if e.absolute_path:
print(f" Path: {'.'.join(str(p) for p in e.absolute_path)}")
errors += 1
return validated, errors
def check_id_uniqueness(files):
"""Pass 2: Condition ID uniqueness within and across files."""
errors = 0
global_ids: dict[str, Path] = {}
for path in files:
rel = _rel(path)
try:
with open(path) as f:
data = yaml.safe_load(f)
except (yaml.YAMLError, OSError):
continue
if not data or not isinstance(data, dict):
continue
local_seen: set[str] = set()
for cond in data.get("conditions", []):
cid = cond.get("id")
if not cid:
continue
if cid in local_seen:
print(f'ID ERROR: duplicate condition id "{cid}" in {rel}')
errors += 1
local_seen.add(cid)
if cid in global_ids and global_ids[cid] != path:
print(f'ID ERROR: condition id "{cid}" used in multiple files')
print(f" First: {_rel(global_ids[cid])}")
print(f" Also: {rel}")
errors += 1
elif cid not in global_ids:
global_ids[cid] = path
return errors
def summarize(files):
"""Print per-room condition counts and type breakdown."""
total = 0
type_counts: dict[str, int] = {}
for path in files:
try:
with open(path) as f:
data = yaml.safe_load(f)
except (yaml.YAMLError, OSError):
continue
if not data or not isinstance(data, dict):
continue
conditions = data.get("conditions", [])
count = len(conditions)
total += count
room = data.get("room_id", "cross_room")
print(f" {room}: {count} conditions")
for cond in conditions:
ct = cond.get("condition_type", "unknown")
type_counts[ct] = type_counts.get(ct, 0) + 1
print(f"\n Total: {total} conditions across {len(files)} files")
if type_counts:
print(" By type:")
for ct in sorted(type_counts):
print(f" {ct}: {type_counts[ct]}")
def main():
check_only = "--check" in sys.argv
if not GAUNTLET_DIR.exists():
print(f"No gauntlet directory at {_rel(GAUNTLET_DIR)}")
print("Checklist validation skipped (no content yet).")
return 0
files = find_checklists()
if not files:
print("No checklist files found under content/gauntlet/.")
print("Checklist validation skipped.")
return 0
schema = load_schema()
# Pass 1: Schema validation
validated, schema_errors = validate_schema(files, schema)
print(f"Pass 1 (schema): {validated} valid, {schema_errors} errors")
if schema_errors > 0:
print(f"\nSchema validation failed ({schema_errors} errors) — skipping ID checks")
return 1
# Pass 2: Condition ID uniqueness
id_errors = check_id_uniqueness(files)
if id_errors > 0:
print(f"Pass 2 (IDs): {id_errors} errors")
total_errors = schema_errors + id_errors
print(f"\nChecklist validation: {validated} valid, {total_errors} errors")
if total_errors > 0:
return 1
if not check_only:
print("\nChecklist summary:")
summarize(files)
return 0
if __name__ == "__main__":
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeroen Schweitzer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+108
View File
@@ -0,0 +1,108 @@
# whatsinagame
A Claude Code starter kit that bootstraps multi-agent team infrastructure for any project.
Clone once, install the `/kit-install` skill globally, run it in any project. Claude detects your tech stack, asks about team composition, and deploys adapted templates — agents, skills, database, decisions, and workflows — tuned to your project. Run `/kit-update` later to pull new skills and improved patterns from the source repo.
## Features
- **Agent archetypes with tunable personalities** — architect, PM, QA, designer, developer, content author, consultant, and more. Each archetype has a personality spectrum (precise, creative, organized, holistic, empathic) that maps to concrete voice, priorities, and constraints.
- **Stakeholder persona panel** — built-in personas (power-user, casual-user, product-advocate, marketer) plus project-specific ones. Personas participate in workshops and reviews to represent real user perspectives.
- **Sprint lifecycle** — plan sprints, assign tickets, write briefings, run standups, track progress, run retros, cut release notes.
- **Workshop orchestration** — multi-agent design workshops with structured rounds, facilitators, and decision capture.
- **Decision tracking** — domain-organized decision files with IDs, status tracking, and cross-references. Never lose a design decision again.
- **Ticketing database** — SQLite-backed project management with CLI tools. No external services required.
- **PR review pipelines** — spawn reviewers matched to branch type (code, copy, visual, audio) in parallel.
- **Worktree workflows** — multi-branch development with sync skills, or single-branch if you prefer.
- **Git host agnostic** — patterns for GitHub (`gh`), Gitea (`tea`), and GitLab (`glab`).
## Profiles
| Profile | Agents | Stakeholders | Skills | Infrastructure |
|---------|--------|--------------|--------|----------------|
| **minimal** | 3 | none | 2 | CLAUDE.md, settings.json, Makefile |
| **standard** | 6 | 2 personas | 8 | + DB, CLIs, decisions, hooks, TEAM.md |
| **full** | 11 | 4 personas | 17 | + Qdrant, briefings, sprints, workshops, DEVOPS.md |
See `skill/references/profile-manifests.md` for exact file lists per profile.
## Installation
```bash
# Clone the kit
git clone https://github.com/yourname/whatsinagame.git
# Install the skills globally
cp -r whatsinagame/skill/ ~/.claude/skills/kit-install/
```
## Usage
```bash
# Navigate to your project
cd my-project
# Start Claude Code
claude
# Run the installer
/kit-install
```
Claude will:
1. Detect your tech stack (package.json, Cargo.toml, project.godot, pyproject.toml, etc.)
2. Ask you about profile, team composition, naming theme, and services
3. Deploy adapted templates — not copies, but project-specific rewrites
4. Initialize the database, verify connectivity, and print a getting-started summary
### Updating
After initial install, check for kit updates periodically:
```bash
/kit-update
```
This compares your installed files against the source repo, shows what's new
(new skills, bug fixes, improved patterns), and lets you cherry-pick updates
without losing your customizations.
## Directory Structure
```
whatsinagame/
README.md # This file
LICENSE # MIT
skill/
SKILL.md # The /kit-install installer skill
references/
profile-manifests.md # What each profile includes
archetype-gallery.md # Agent archetypes and stakeholder personas
personality-guide.md # Tuning personality to role
git-host-patterns.md # CLI patterns per git host
customization-examples.md # Inspiration from The Settled Reach
templates/
.claude/
agents/ # Agent personality templates
skills/ # Skill templates (git-commit, ticket, pr-review, etc.)
.config/
hooks/ # Git hook templates
decisions/ # Decision tracking templates
static/
db/
schema.sql # Ticketing database schema
connectors/ # CLI tools (ticket, sprint, sqlite, qdrant)
tests/
test-install.sh # Smoke test for all three profiles
test-completeness.sh # Kit self-check
```
## How It Works
The `/init-team` skill is the heart of the kit. It doesn't just copy files — it reads the reference templates, understands your project context, and writes adapted versions. An architect agent for a game project gets different priorities than one for a SaaS API. A PM for a solo developer gets a lighter process than one for a 5-person team.
The templates in `templates/` are examples of what good output looks like. Claude uses them as reference material, not as copy-paste sources.
## Credits
Extracted from [The Settled Reach](https://github.com/jpmschweitzer/settled-reach), a top-down immersive sim built with a 20-agent Claude Code team. The patterns in this kit emerged from building that game — sprint planning, design workshops, decision tracking, PR reviews, and all the coordination infrastructure that makes multi-agent development actually work.
+181
View File
@@ -0,0 +1,181 @@
---
name: kit-install
description: >
Bootstrap multi-agent team infrastructure for any project. Use when
starting a new project, adding team infrastructure, or running /kit-install.
Detects tech stack, asks about team composition, and deploys adapted
templates — agents, skills, DB, decisions, and workflows. For updating
an existing installation, use /kit-update instead.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Write, Edit, AskUserQuestion
---
# /kit-install — Multi-Agent Team Bootstrap
You are the kit installer. Your job is to bootstrap multi-agent team infrastructure for the user's project. Follow these five steps exactly.
## Step 1: Detect Context
Scan the project root for tech stack indicators. Read any files you find (don't guess — read them):
- `package.json` — Node.js/TypeScript project. Note framework (React, Next.js, Express, etc.)
- `Cargo.toml` — Rust project. Note workspace members, key dependencies (bevy, actix, tokio, etc.)
- `project.godot` — Godot project. Note Godot version from the file.
- `pyproject.toml` or `setup.py` — Python project. Note framework (Django, FastAPI, Flask, etc.)
- `go.mod` — Go project. Note module path and key dependencies.
- `pom.xml` or `build.gradle` — Java/Kotlin project. Note framework (Spring, Quarkus, etc.)
- `*.sln` or `*.csproj` — .NET project. Note framework version.
- `Makefile`, `CMakeLists.txt` — C/C++ project.
- `mix.exs` — Elixir project.
- `Gemfile` — Ruby project.
Also detect:
- **Git remote**: Run `git remote -v` to determine the host (GitHub, Gitea, GitLab, Bitbucket, or other). This determines which CLI patterns to use.
- **Existing `.claude/` directory**: If one exists, note what's already present. The installer should augment, not overwrite.
- **Existing `CLAUDE.md`**: If present, the installer should merge new instructions into it rather than replacing it.
- **Monorepo structure**: Check for workspace files, multiple package.json files, or other signs of a monorepo.
Summarize what you found before proceeding.
## Step 2: Ask User
Use AskUserQuestion to gather project preferences. Ask these questions (combine into as few AskUserQuestion calls as possible):
**Question 1: Profile**
- **minimal** (Recommended for solo/small projects) — 3 agents (architect, project-manager, qa-engineer), basic skills (git-commit, skill-create), CLAUDE.md + settings.json + Makefile
- **standard** — 6 agents, ticketing DB + CLIs, decisions tracking, git hooks, 8 skills
- **full** — 11 agents, Qdrant semantic search, briefings, sprints, workshops, 17 skills
**Question 2: Project identity**
Ask for project name and a one-line description. Suggest defaults based on the git remote or directory name.
**Question 3: Team composition**
Based on the detected tech stack and chosen profile, suggest which archetypes to include. Show the list from `references/archetype-gallery.md` filtered to the chosen profile's agent count. Let the user swap archetypes.
**Question 4: Stakeholder personas** (standard/full only)
Suggest personas relevant to the detected project type. Let the user customize.
**Question 5: Git host**
Confirm the detected git host. Ask for any required credentials/flags:
- GitHub: usually no extra config needed
- Gitea: needs `--login`, `--repo`, `--output simple` flags
- GitLab: usually no extra config needed
**Question 6: Worktree vs single-branch** (standard/full only)
- Single-branch (Recommended for most projects) — all work on feature branches from main
- Worktree — multiple long-lived branches, each in its own worktree directory
**Question 7: Naming theme** (optional, all profiles)
Ask the user if they want a naming theme for their agents. This adds personality and makes the team memorable. Suggest themes based on the project domain:
- **Literature**: Characters from a favorite book series (e.g., The Settled Reach uses Peter F. Hamilton's Commonwealth characters)
- **Mythology**: Greek gods, Norse mythology, Egyptian pantheon
- **Science**: Famous scientists, elements, constellations
- **Music**: Composers, instruments, genres
- **Nature**: Trees, rivers, mountains, animals
- **None**: Use the archetype names directly (architect, qa-engineer, etc.)
If the user chooses a theme, rename each agent file and adapt the personality to include a thematic connection. For example, if the theme is "Greek mythology" and the architect archetype becomes "athena", the personality intro might reference Athena's wisdom and strategic thinking.
**Question 8: Services** (full only)
- Qdrant URL (or skip for grep-only document search)
- Ollama URL and embedding model (or skip)
## Step 3: Deploy Static Files
Copy static infrastructure files from the kit's `static/` directory:
1. **Database**: Copy `static/db/schema.sql` and `static/db/connectors/` to the project's `db/` directory.
2. **Config**: Write `db/connectors/config.json` with the user's service URLs (or sensible defaults).
3. **Initialize DB**: Run `python3 db/connectors/sqlite_connector.py init` (or equivalent) to create the SQLite database.
4. **Git hooks** (standard/full): Read the hook templates from `templates/.config/hooks/` and adapt for the project.
5. **Makefile**: If no Makefile exists, write one with standard targets (build, test, lint, format, clean, help). Adapt targets to the detected tech stack.
Do NOT copy files blindly. Read each static file first and confirm it's appropriate for the target project.
## Step 4: Adapt and Write Templates
This is the critical step. For each template file, you must:
1. **Read** the reference template from the kit's `templates/` directory
2. **Understand** its structure and purpose
3. **Adapt** it for the target project — rewrite names, paths, tech stack references, personality notes, tool lists
4. **Write** the adapted version to the target project
### Files to adapt (by profile):
**All profiles (minimal+):**
- `CLAUDE.md` — Project instructions. Read `templates/CLAUDE.md.template` (if it exists) or compose from scratch using the detected tech stack, chosen profile, and project identity. Include: project structure, agent instructions, commit conventions, file conventions, dev ops section.
- `.claude/settings.json` — Agent settings. Include model preferences and any allowed tools.
- Agent files in `.claude/agents/` — One `.md` file per agent. Read the archetype templates, adapt personality and tools for this project. See `references/personality-guide.md` for how to write good personality sections.
**Standard+ profiles:**
- `TEAM.md` — Team roster with agent names, roles, and descriptions.
- `decisions/README.md` — Decision domain index.
- Decision domain files — Create domain files based on the project type. A web app might have `decisions/architecture.md`, `decisions/api.md`, `decisions/frontend.md`. A game might have `decisions/engine.md`, `decisions/gameplay.md`.
- Skill files in `.claude/skills/` — Read each skill template, adapt for the project's git host, tech stack, and conventions. See `references/git-host-patterns.md` for host-specific patterns.
**Full profile:**
- `docs/briefings/` — One briefing per agent with project context.
- `docs/sprints/` — Sprint directory scaffolding.
- `docs/workshops/` — Workshop directory scaffolding.
- `DEVOPS.md` or `docs/DEVOPS.md` — Build, test, lint, CI procedures.
- Additional skill files for the full skill set.
### Adaptation guidelines:
- **Names**: Replace all project-specific names. "The Settled Reach" becomes the user's project name. If the user chose a naming theme, rename agents to match (e.g., "architect" → "athena" for Greek mythology, "qa-engineer" → "hoshe" for Commonwealth). Weave the thematic connection into each agent's personality intro for flavor.
- **Paths**: Replace all hardcoded paths with paths appropriate for the target project's structure.
- **Tech stack**: Replace technology references. Godot becomes React (or whatever was detected). Rust becomes Python. Adjust tool recommendations accordingly.
- **Personality**: Keep the personality structure but adapt the voice. An architect for a REST API has different concerns than an architect for a game engine.
- **Scopes**: Replace commit scopes with ones appropriate for the target project. A web app might use: api, frontend, auth, db, deploy, docs.
- **Tools**: Adjust tool lists per agent based on what makes sense for the tech stack.
## Step 5: Verify
After deploying all files:
1. **File check**: Use Glob to verify all expected files were created. List any missing files.
2. **DB check**: Run a simple query against the SQLite database to confirm it's working.
3. **CLI check**: Run `db/connectors/ticket --help` and `db/connectors/sprint --help` (standard+) to verify CLIs are functional.
4. **Decisions check**: Verify decision files have correct structure with domain index entries.
5. **Agent check**: Verify all agent files have valid YAML frontmatter.
6. **Kit tracking**: Write `.claude/kit-source.json` so `/kit-update` can find the source repo later:
```json
{
"source_path": "<path to whatsinagame clone>",
"installed_version": "<today's date>",
"installed_profile": "<minimal|standard|full>",
"last_checked": "<today's date>"
}
```
Print a getting-started summary:
```
## Setup Complete
**Project**: {project_name}
**Profile**: {profile}
**Agents**: {count} ({list of names})
**Skills**: {count} ({list of names})
**Database**: {path} (initialized)
### Next steps:
1. Review CLAUDE.md and customize any sections
2. Review agent personalities in .claude/agents/
3. Start a new Claude Code session to pick up the new configuration
4. Try: /git-commit, /ticket list, /pr-review
5. Run /kit-update periodically to check for new skills and patterns
```
## Reference Files
The following reference files in `skill/references/` provide detailed guidance:
- **`profile-manifests.md`** — Exact file lists for each profile tier.
- **`archetype-gallery.md`** — All agent archetypes with roles, personality defaults, and adaptation examples.
- **`personality-guide.md`** — How to write effective agent personalities with the precise/creative/organized/holistic/empathic spectrum.
- **`git-host-patterns.md`** — CLI command patterns for GitHub, Gitea, and GitLab.
- **`customization-examples.md`** — How The Settled Reach customized these patterns, as inspiration.
Read these files when you need detailed guidance on any of the above steps.
@@ -0,0 +1,307 @@
# Agent Archetype Gallery
All available agent archetypes and stakeholder personas. The installer selects from these based on the chosen profile and project type.
## Agent Archetypes
### architect
**Role**: Technical architecture, feasibility evaluation, code review, system design.
**Personality style**: Precise (primary), Holistic (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch
**When to use**: Every project needs an architect. This is a core archetype in all profiles.
**When NOT to use**: Never skip this one.
**What they care about**: Structural integrity, separation of concerns, performance implications, technology fit. They ask "will this scale?" and "what breaks if we change this?"
**What they don't do**: Implementation grunt work, content writing, UI polish.
**Adaptation examples**:
- Game project: Becomes the engine architect — evaluates rendering pipelines, ECS patterns, client-server boundaries.
- SaaS API: Becomes the platform architect — evaluates database schemas, API contracts, service boundaries.
- CLI tool: Becomes the systems architect — evaluates module structure, dependency management, extensibility patterns.
---
### project-manager
**Role**: Task tracking, sprint planning, prioritization, coordination, process management.
**Personality style**: Organized (primary), Empathic (secondary)
**Default model**: haiku
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Every project needs coordination. Core archetype in all profiles.
**When NOT to use**: Never skip this one either.
**What they care about**: Velocity, blocker resolution, clear task definitions, realistic scope. They ask "what's actually blocking this?" and "do we have acceptance criteria?"
**What they don't do**: Architecture decisions, code implementation, design opinions.
**Adaptation examples**:
- Game project: Tracks feature milestones, coordinates art/audio/code pipelines, manages playtest cycles.
- SaaS API: Tracks API versioning, coordinates frontend/backend, manages deployment windows.
- Open source: Tracks contributor PRs, manages release cycles, coordinates RFC processes.
---
### qa-engineer
**Role**: Testing, bug investigation, test plans, quality assurance, verification.
**Personality style**: Precise (primary), Organized (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Any project that has tests or should have tests. Core archetype in all profiles.
**When NOT to use**: Very early prototypes where testing is premature.
**What they care about**: Coverage, edge cases, regression prevention, reproducibility. They ask "what happens when the input is empty?" and "did we test the error path?"
**What they don't do**: Feature design, architecture, content. They verify, they don't create.
**Adaptation examples**:
- Game project: Writes gameplay tests, verifies simulation determinism, tests client-server sync.
- SaaS API: Writes integration tests, verifies API contracts, tests auth edge cases.
- Data pipeline: Writes data validation tests, verifies transformation correctness, tests failure recovery.
---
### designer
**Role**: Systems design, UX, feature design, interaction patterns, information architecture.
**Personality style**: Holistic (primary), Creative (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write
**When to use**: Projects with user-facing interfaces or complex system interactions. Standard+ profiles.
**When NOT to use**: Pure backend services, libraries, or CLI tools where UX is minimal.
**What they care about**: User mental models, system coherence, interaction flow, emergent behavior from system combinations. They ask "does this create interesting decisions?" and "how do these systems interact?"
**What they don't do**: Implementation, pixel-perfect visual design, backend optimization.
**Adaptation examples**:
- Game project: Designs game mechanics, evaluates fun factor, maps systems interactions.
- SaaS product: Designs user flows, evaluates feature coherence, maps information architecture.
- Developer tool: Designs CLI ergonomics, evaluates API surface, maps command interactions.
---
### developer-backend
**Role**: Backend implementation, API design, data modeling, server-side logic.
**Personality style**: Precise (primary), Organized (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Any project with backend code. Standard+ profiles.
**When NOT to use**: Pure frontend projects, content-only projects.
**What they care about**: Correctness, performance, clean interfaces, error handling. They write code that works first, then optimize.
**What they don't do**: Frontend/UI work, visual design, content writing, project management.
**Adaptation examples**:
- Game project: Implements simulation server, entity systems, network protocol.
- SaaS API: Implements REST/GraphQL endpoints, database migrations, background jobs.
- Data pipeline: Implements ETL processes, data transformations, scheduling.
---
### content-author
**Role**: Written content, documentation, copywriting, in-app text.
**Personality style**: Creative (primary), Empathic (secondary)
**Default model**: haiku
**Default tools**: Read, Glob, Grep, Edit, Write
**When to use**: Projects that need substantial written content. Standard+ profiles.
**When NOT to use**: Libraries or tools where documentation is the only text need (the architect or PM can handle that).
**What they care about**: Voice consistency, clarity, emotional resonance, audience awareness. They ask "who's reading this?" and "does this feel right?"
**What they don't do**: Code implementation, architecture decisions, visual design.
**Adaptation examples**:
- Game project: Writes dialogue, item descriptions, lore entries, UI text, tutorial copy.
- SaaS product: Writes marketing copy, help documentation, onboarding flows, email templates.
- Developer tool: Writes README, tutorials, API documentation, changelog entries.
---
### consultant
**Role**: External perspective, architecture evaluation, technology assessment, second opinions.
**Personality style**: Holistic (primary), Precise (secondary)
**Default model**: opus
**Default tools**: Read, Glob, Grep, Bash, WebSearch, WebFetch
**When to use**: When you need a reality check on scope, technology choices, or architectural tradeoffs. Full profile.
**When NOT to use**: Day-to-day implementation work. This is a specialist you bring in for evaluations.
**What they care about**: Trade-offs, alternatives, industry patterns, risk assessment. They ask "have you considered X?" and "what's the cost of this choice in 6 months?"
**What they don't do**: Regular implementation, project management, content. They evaluate and advise.
**Adaptation examples**:
- Game project: Evaluates engine choice, rendering pipeline trade-offs, multiplayer architecture.
- SaaS product: Evaluates cloud provider choices, scaling strategies, build-vs-buy decisions.
- Startup: Evaluates technical feasibility, MVP scope, technology stack fit.
---
### visual-designer
**Role**: Art direction, UI consistency, visual coherence, asset style guides.
**Personality style**: Creative (primary), Precise (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Projects with significant visual elements. Full profile.
**When NOT to use**: CLI tools, backend services, anything without a visual interface.
**What they care about**: Visual consistency, color harmony, typography, spatial rhythm. They ask "does this feel cohesive?" and "what's the visual hierarchy?"
**What they don't do**: Backend code, content writing, project management.
**Adaptation examples**:
- Game project: Defines art style, sprite standards, UI component library, color palettes.
- Web app: Defines design system, component patterns, responsive layouts, theme tokens.
- Mobile app: Defines platform-specific patterns, icon sets, animation standards.
---
### audio-designer
**Role**: Sound design, audio pipelines, spatial audio, music direction.
**Personality style**: Creative (primary), Holistic (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Projects with audio requirements. Full profile.
**When NOT to use**: Most non-game, non-media projects.
**What they care about**: Audio atmosphere, sound-gameplay relationship, spatial design, emotional cues. They ask "what should this moment sound like?" and "does the audio reinforce the mechanic?"
**What they don't do**: Visual design, backend code, content writing.
**Adaptation examples**:
- Game project: Designs soundscapes, ambient layers, SFX palettes, audio propagation.
- Media app: Designs notification sounds, transition audio, playback experience.
- Accessibility tool: Designs audio cues, screen reader integration, sonification.
---
### developer-frontend
**Role**: Frontend implementation, UI code, client-side logic, rendering.
**Personality style**: Precise (primary), Creative (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Projects with substantial frontend code. Full profile.
**When NOT to use**: Backend-only services, CLI tools, data pipelines.
**What they care about**: Responsiveness, accessibility, component architecture, state management. They write UI code that's both functional and maintainable.
**What they don't do**: Backend implementation, content writing, project management.
**Adaptation examples**:
- Game project: Implements game client, rendering pipeline, UI systems, input handling.
- Web app: Implements React/Vue/Svelte components, state management, API integration.
- Desktop app: Implements Electron/Tauri UI, native integrations, platform-specific behavior.
---
### librarian
**Role**: Documentation, search indexing, knowledge management, cross-reference maintenance.
**Personality style**: Organized (primary), Precise (secondary)
**Default model**: haiku
**Default tools**: Read, Glob, Grep, Edit, Write, Bash
**When to use**: Projects with substantial documentation or decision history. Full profile.
**When NOT to use**: Small projects where the PM can handle documentation.
**What they care about**: Findability, consistency, completeness, cross-references. They ask "is this recorded?" and "can someone find this in 3 months?"
**What they don't do**: Architecture, implementation, design decisions. They record and organize.
**Adaptation examples**:
- Game project: Maintains design docs, decision records, lore bible, discussion archives.
- SaaS product: Maintains API docs, runbooks, architecture decision records, onboarding guides.
- Open source: Maintains contributor docs, changelog, migration guides, FAQ.
---
### security-specialist
**Role**: Threat modeling, vulnerability assessment, secure code review, compliance guidance.
**Personality style**: Precise (primary), Holistic (secondary)
**Default model**: sonnet
**Default tools**: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch
**When to use**: Projects handling user data, authentication, external APIs, or any sensitive operations. Full profile.
**When NOT to use**: Internal tools with no external attack surface and no sensitive data.
**What they care about**: Attack surfaces, trust boundaries, input validation, secrets management, dependency supply chain. They ask "who controls that input?" and "what's the threat model?"
**What they don't do**: Feature design, project management, content writing. They secure, they don't build.
**Adaptation examples**:
- Game project: Reviews multiplayer auth, anti-cheat boundaries, save file integrity, mod sandboxing.
- SaaS API: Reviews auth flows, API rate limiting, RBAC, data encryption, OWASP compliance.
- Open source: Reviews dependency supply chain, CI/CD pipeline security, contributor access controls.
---
## Stakeholder Personas
Stakeholder personas represent user perspectives in workshops and reviews. They don't write code — they evaluate from the user's point of view.
### Built-in Personas
#### power-user
**Perspective**: Technical users who push limits, want efficiency, keyboard shortcuts, advanced features.
**In workshops**: Asks "can I do this faster?" and "what's the power-user path?"
**Default tools**: Read, Glob, Grep
#### casual-user
**Perspective**: Non-technical users who want simplicity, clear guidance, forgiving interfaces.
**In workshops**: Asks "what if I don't know what this means?" and "is there a simpler way?"
**Default tools**: Read, Glob, Grep
#### product-advocate
**Perspective**: Champions the product vision, evaluates wow factor, thinks about what makes this special.
**In workshops**: Asks "is this exciting?" and "would someone tell their friend about this?"
**Default tools**: Read, Glob, Grep
#### marketer
**Perspective**: External messaging, positioning, what makes this sellable or shareable.
**In workshops**: Asks "how do I explain this in one sentence?" and "what's the hook?"
**Default tools**: Read, Glob, Grep
### Project-Specific Persona Examples
These show how to create personas tailored to your project type:
**Game project**:
- `speedrunner` — Looks for optimization paths, sequence breaks, emergent exploits
- `lore-enthusiast` — Evaluates world consistency, narrative depth, discoverable details
- `accessibility-player` — Evaluates colorblind modes, difficulty options, control remapping
**SaaS product**:
- `enterprise-admin` — Evaluates multi-tenant, SSO, audit logs, compliance features
- `api-integrator` — Evaluates developer experience, documentation quality, SDK ergonomics
- `free-tier-user` — Evaluates onboarding, upgrade motivation, value perception
**Developer tool**:
- `first-time-user` — Evaluates getting-started experience, error messages, documentation
- `ci-pipeline-user` — Evaluates non-interactive mode, exit codes, machine-readable output
- `plugin-author` — Evaluates extensibility, hook points, API stability
**Mobile app**:
- `commuter` — Evaluates offline mode, quick interactions, one-handed use
- `accessibility-user` — Evaluates screen reader, dynamic type, motor accessibility
- `privacy-conscious-user` — Evaluates permissions, data collection, transparency

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