Compare commits
44
Commits
da63aa580e
...
v0.1.9
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcc1wwpk3moh1
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://njychcgqlvtv
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3fj584itowcl
|
||||
@@ -0,0 +1 @@
|
||||
uid://vw0iqhai1kum
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw0yyp8mrchi7
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci76ggq7n33w7
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bas5l8rnvsjly
|
||||
@@ -0,0 +1 @@
|
||||
uid://mg1wnv1ydc6m
|
||||
@@ -0,0 +1 @@
|
||||
uid://foiy3bk1jko7
|
||||
@@ -0,0 +1 @@
|
||||
uid://w7k3k1584ir7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://br7kv0dyk3w2k
|
||||
@@ -0,0 +1 @@
|
||||
uid://cefjgd1iqi7qn
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjd6g15syl2qy
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwdssbevd8gqq
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://btwe5f7tmm3o4
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://2dtu0xe0odtf
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://wkkt2nhmygtm
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhdp146dcog3x
|
||||
@@ -0,0 +1 @@
|
||||
uid://7y327ugttryy
|
||||
@@ -0,0 +1 @@
|
||||
uid://ct55qobqip5nn
|
||||
@@ -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)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
@@ -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.
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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={})",
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
&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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+194
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user