Compare commits

...
31 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 e2d2211ff6 chore(meta): release v0.1.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:13:49 +01:00
jpmschweitzer 62d48dc7b4 Merge remote-tracking branch 'origin/client' 2026-02-19 09:10:37 +01:00
jpmschweitzerandClaude Opus 4.6 7640a9ab87 fix(client): address PR #38 review — 6 warnings + 4 suggestions
Hoshe:
- COLOR_FADE_DURATION 0.7 → 0.5 to match D-033 spec ("0.5s fade")
- Gauntlet guard tests now exercise InputMapper._unhandled_input()
  with synthesized InputEventKey instead of asserting a bool
- Buffer clearing tests use SimBridge pipeline instead of manual nulls
- Add mid-transition re-trigger test (rapid relationship changes)
- Add relationship field to test snapshot NPC

Tyre:
- Add _teleport_in_progress flag to defer smoothing re-enable by one
  frame after teleport (prevents same-_process() re-enable race)
- Add _test_gauntlet_mode to SimBridge test snapshot
- Extract TELEPORT_DISTANCE_THRESHOLD constant, mirror in tests
- Add comments: flash preemption, modulate/color independence
- Rename "hub teleport" → "Gauntlet dev teleport" in code comments
  to clarify this is not production fast-travel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 6d99f9bc8c docs(decisions): add Q-027 fast-travel system design
Re-scope #501 hub teleport as Gauntlet-only dev tool. Production
travel must be diegetic: local gate → system gate → target menu →
destination, gated by region safety, with asymmetric information
exposure (NPCs observe arrivals/departures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:03:16 +01:00
jpmschweitzerandClaude Opus 4.6 58e592fd5e feat(client): confrontation D-033 color shift (#521, D-063)
Entity renderer now tracks relationship per entity and tweens D-033
tint color over 0.7s when relationship changes (e.g. on confrontation
delivery). Uses manual lerp in _process() for testability instead of
SceneTree tweens. Cursor hover tint cascades automatically via
Constants.color_for_entity_kind().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:12 +01:00
jpmschweitzerandClaude Opus 4.6 7274151671 feat(client): hub teleport UX — Home key, fade transition (#501)
Home key sends TeleportToHub in Gauntlet mode. Camera snaps to hub
spawn with 0.3s fade-from-black. Clears dialogue/monologue buffers
on teleport. Teleport detection uses distance threshold (>5 tiles)
so future teleport types get the transition for free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:02 +01:00
jpmschweitzer fd2101368e Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:35:15 +01:00
jpmschweitzerandClaude Opus 4.6 503644beb1 fix(client): address PR #36 re-review — 7 items from Hoshe + Tyre
Warnings: facing indicator tests use Godot-normalized rotation range
(-PI, PI] instead of raw addition (SW/W/NW in test_rendering,
West in test_client_p3). Suggestions: cache font in world_radial
_draw(), fix docstring on deactivate_insert() trigger, document
tile-coordinate system on _eval_player_near (D-066), add public
reset_facing_state() to InputMapper (D-030 testability).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:28:37 +01:00
jpmschweitzer c6b4017621 Merge remote-tracking branch 'origin/server' 2026-02-18 18:26:27 +01:00
jpmschweitzer 54f43ee58c Merge remote-tracking branch 'origin/ci'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:26:23 +01:00
jpmschweitzerandClaude Opus 4.6 52eb8bfc81 fix(simulation): address PR #37 re-review — stale strings, test coverage, confrontation symmetry
Hoshe re-review (3 items):
- content_scaling.rs:185: doc "StableId 0-51" → references constant
- content_scaling.rs:256: assertion message "id<=51" → "id <= max_baseline_id"
- input.rs: teleport test now asserts WalkAwayRequest + ConfrontationDelivered
  are cleared (was only checking TalkRequest + ActiveDialogue)

Tyre re-review (2 items):
- input.rs: same teleport test coverage (overlaps Hoshe #3)
- dialogue.rs: process_confrontation_response now inserts RoutineDeviation
  with DeviationTrigger::Confrontation — symmetric with walk-away path.
  Test updated to verify deviation is recorded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:22:32 +01:00
jpmschweitzerandClaude Opus 4.6 d92a2e5f50 fix(simulation): address PR #37 review — doc corrections, race fix, marker cleanup
Hoshe review (4 items):
- types.rs: doc comment "Current: 6" → "Current: 9"
- dialogue.rs: walk-away doc duplicated numbering (items 4-5 were 2-3)
- test_world/mod.rs: comment "Reset plates at 49-51" → "49-55"
- content_scaling.rs: magic number 51 → constants::RESET_PLATE_STABLE_IDS.1

Tyre review (3 items):
- knowledge/types.rs: guard comments on decrement() floor at Hostile
- input.rs: TeleportToHub now clears ConfrontationDelivered marker
- content_scaling.rs: same magic number fix (covered above)

Additional:
- content_runtime.rs: barrier-based shutdown handshake fixes TCP RST
  race condition under parallel test execution
- dialogue_room.rs: clippy type_complexity allow on NPCS tuple array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:50:22 +01:00
jpmschweitzerandClaude Opus 4.6 8d8da1b546 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:23 +01:00
jpmschweitzerandClaude Opus 4.6 d008874c14 chore(skills): add commit step to push-pr workflow
push-pr now runs /commit first when uncommitted changes are detected,
making it a one-stop commit→push→PR command. Added Skill to
allowed-tools for cross-skill invocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:11 +01:00
jpmschweitzerandClaude Opus 4.6 919ef39cbd fix(client): address PR #36 review — 10 items from Hoshe + Tyre
Critical:
- deactivate_insert() now called when selecting non-Insert spoke,
  cancelling with no selection, or pressing Escape while insert is
  active. Fixes simulation staying paused permanently after Insert.

Warnings:
- Checklist conditions with empty id excluded from get_results() and
  get_total_count() — prevents impossible-to-complete checklists.
  Warns at load time when empty-id conditions are found.
- _content_base now checks res://content/ first (exported builds),
  falls back to ../content for editor/dev mode.
- 26 new tests for D-054 functions: _angle_to_octant (8 octants),
  _snap_to_octant_dir (9 cases incl. zero/tiny), _wasd_to_world_dir
  (8 facing/movement combos). New test file: test_input_mapper_facing.gd.

Suggestions:
- Cached get_theme_default_font() in checklist overlay _ready().
- Documented InputMapper → GameState coupling as intentional.
- Documented YAML parser # truncation limitation.
- _insert_active reset on Escape dismiss (Tyre #3).
- SimBridge test mode SetFacing reads action_data.facing instead of
  InputMapper global (Tyre #4).
- Removed dead _facing_to_rotation() from entity_renderer.gd (Tyre #5).
- Fixed 2 failing facing indicator tests to use InputMapper.facing_angle
  instead of GameState.player_facing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:09:38 +01:00
jpmschweitzerandClaude Opus 4.6 d0596d2763 fix(ci): address PR #35 review comments
- Document non-blocking receive contract in perf_bench.rs docstring,
  confirming no TCP deadlock race (Hoshe #1, critical)
- Make shadowcast parser order-independent — flush on new config header
  instead of requiring Recursive after Symmetric (Hoshe #2)
- Fix p95 calculation: use floor(0.95*(N-1)) nearest-rank instead of
  ceil(0.95*N)-1 which was off-by-one at N=50 (Hoshe #4)
- Error on --compare when no baseline file exists (Hoshe #5)
- Add D-031 10tps assumption comment to TICK_BUDGET_US (Tyre #1)
- Strengthen snapshot assertion: require warmup + half measurement
  window instead of warmup + 1 (Tyre #2)
- Regenerate baseline with corrected p95 (356µs, was 526µs)

Hoshe #3 (.PHONY) was already addressed — perf-baseline is in the
.PHONY declaration on Makefile line 10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:07:46 +01:00
jpmschweitzerandClaude Opus 4.6 6c62e2228f test(simulation): sprint 10 — replay loading, content scaling, serialization v9, observer tests
#483: Replay loading in test-client — JSONL file loading, tick-scheduled
PlayerInput sending, 13 unit tests, 3 sample replay files.
#500: Content scaling test — baseline + extra NPC comparative, tick budget
assertion (D-026), determinism check across content packs.
#514: Serialization tests for protocol v9 — blocked_entities roundtrip,
backward compat (v5→v9, v8→v9), regenerated msgpack fixtures.
Observer perception tests for confrontation + walk-away mechanics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:27 +01:00
jpmschweitzerandClaude Opus 4.6 273d29f26f feat(simulation): sprint 10 — hub teleport, confrontation response, walk-away phase 2, gauntlet rooms 4-7, blocked_entities debug field
#491: PlayerAction::TeleportToHub — moves player to hub spawn, clears
dialogue/monologue/interaction buffer, Gauntlet-only with log warning.
#520: ConfrontationDelivered event — Tier 2 animation shift, relationship
state decrement (D-033), monologue spike emission.
#519: Walk-away Phase 2 — NPC animation shift + routine deviation on
dialogue exit (D-064).
#498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd
Plaza, Dialogue Room with constants and wiring.
#514: blocked_entities Vec<u64> on ObserverSnapshot, protocol v9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:14 +01:00
jpmschweitzerandClaude Opus 4.6 ab85fbd652 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:50 +01:00
jpmschweitzerandClaude Opus 4.6 eb1d89ea86 feat(client): room reset UX, insert pause wiring, auto-checklist (#502, #518, #503)
Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s
screen flash on room_reset monologue, 'Reset Room' verb via existing
nearby_interactions.

Insert pause (#518, D-058): explicit PauseSimulation on insert open,
ResumeSimulation on close. Replaces toggle-style pause with idempotent
pair per D-058.

Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates
7 condition types against GameState with latching. ChecklistOverlay
renders progress in gauntlet mode only. 48 tests covering parser,
evaluation, latching, visibility, and integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:35 +01:00
jpmschweitzerandClaude Opus 4.6 97cb69e6a4 feat(client): mouse-relative facing and movement (#526, D-054)
Mouse position now determines facing direction as a client-side float.
WASD remapped: W=toward cursor, S=away, A/D=strafe. Facing octant
derived from mouse angle and sent to server via SET_FACING action only
when it changes. EntityRenderer facing indicator uses continuous angle
for smooth rotation. SimBridge test mode updated to handle SetFacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:19 +01:00
jpmschweitzerandClaude Opus 4.6 80d5a57b5d feat(client): introduce Michroma font and implant UI theme (#517)
Add Michroma-Regular.ttf (OFL license) as the game font. Create
FontVariation with +1px tracking and a global Theme resource with
cyan-white (#E0F7FA) implant text color. Wire theme into project.godot
so all Label/RichTextLabel/Button/LineEdit nodes inherit automatically.
Add IMPLANT_TEXT_COLOR/DIM/PULSE constants to constants.gd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:08 +01:00
jpmschweitzerandClaude Opus 4.6 370649b3df chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:00 +01:00
jpmschweitzerandClaude Opus 4.6 f13aafb1dd docs(meta): add asset pipeline and remote terminal proxy ideas to scratchpad
Two new scratchpad entries: asset generation pipeline improvements
(registry, status tracking, prompt versioning) and a remote terminal
proxy concept for mobile monitoring of Claude Code permission prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:52:49 +01:00
jpmschweitzerandClaude Opus 4.6 c1d7c072ae chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:44:34 +01:00
jpmschweitzerandClaude Opus 4.6 08052c6459 feat(ci): add performance baseline tooling (#499)
Adds `make perf-baseline` — boots the full server plugin stack with
real content, measures 50 ticks (5 warmup), captures per-tick timing,
entity counts, and process RSS. Includes shadowcast benchmarks. Saves
structured JSON to tests/perf/baseline.json for regression detection.
Supports --compare mode (>20% threshold).

First baseline: mean 366µs, p95 526µs (0.5% of D-026 100ms budget).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:42:09 +01:00
jpmschweitzerandClaude Opus 4.6 76efc979b9 fix(ci): correct bidirectional relationship check target (#515)
Check 9 tested `target in npc_rels` which missed NPCs with no
relationship entries. Changed to `target in self.npcs` so the
advisory warning fires correctly for all NPCs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:41:35 +01:00
jpmschweitzerandClaude Opus 4.6 c2a3a251b4 feat(skills): start-sprint main workflow as state machine
On main, the skill now assesses sprint state and does the next right
thing: close active sprint (Case A), activate planned sprint (Case B),
or suggest /plan-sprint (Case C). Cases chain automatically — closing
flows into activation if a planned sprint is waiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:15:56 +01:00
jpmschweitzerandClaude Opus 4.6 b4a3784218 docs(sprints): add Sprint 10 Prove briefings
Server (7), client (7), CI (3), audio (1) — 18 tickets total.
Gauntlet expansion, audio wiring, client UX polish, CI pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:12:45 +01:00
jpmschweitzerandClaude Opus 4.6 407018ecdf chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 973c804b52 feat(meta): add project.yaml as version source of truth
Technical project descriptor with game architecture, simulation
parameters, and content model. Version field (0.1.9) is now the
canonical version — Cargo.toml synced, CLAUDE.md and start-sprint
skill updated to reference it during release workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:29 +01:00
83 changed files with 5862 additions and 180 deletions
+22 -10
View File
@@ -8,7 +8,7 @@ description: >
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
---
# Push PR Skill
@@ -34,17 +34,29 @@ git branch --show-current
If on `main`, stop: "You're on main. Switch to a team branch first."
### 2. Check for unpushed commits
### 2. Commit uncommitted changes
```bash
git status
```
If there are uncommitted changes (staged or unstaged), run the **commit skill**
first. Use the `/commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
proceeding.
If the working tree is clean (no uncommitted changes), skip to step 3.
### 3. Check for unpushed commits
```bash
git fetch --all
git status
git log --oneline origin/<branch>..<branch>
```
If no unpushed commits, skip to step 4 (PR check).
If no unpushed commits, skip to step 5 (PR check).
### 3. Check for conflicts with main
### 4. Check for conflicts with main
```bash
git merge-tree --write-tree origin/main HEAD 2>&1
@@ -59,7 +71,7 @@ git merge origin/main --no-edit
If merge conflicts, **stop and report** — let the user resolve.
If clean, continue.
### 4. Push
### 5. Push
```bash
git push origin <branch>
@@ -67,7 +79,7 @@ git push origin <branch>
If push fails, stop and report. Never force-push without explicit request.
### 5. Check for existing PR
### 6. Check for existing PR
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
@@ -76,9 +88,9 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
Match current branch name in PR list.
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
- **No PR**: Continue to step 6.
- **No PR**: Continue to step 7.
### 6. Create a new PR
### 7. Create a new PR
```bash
git log --oneline main..<branch>
@@ -103,7 +115,7 @@ tea pr create \
Report PR URL when done.
### 7. Update ticket status to review
### 8. Update ticket status to review
Scan all commit messages in the PR for ticket references (`#NNN`):
+71 -21
View File
@@ -1,12 +1,12 @@
---
name: start-sprint
description: >
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.
Manage the sprint lifecycle from main, or 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. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
@@ -32,12 +32,22 @@ the team branch workflow (steps 28).
---
## Main branch workflow (sprint close + version bump)
## Main branch workflow (sprint lifecycle management)
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.
When `/start-sprint` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
### M1. Close the active sprint
```bash
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
#### A1. Close the active sprint
```bash
db/connectors/sprint stop
@@ -46,11 +56,17 @@ 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
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `project.yaml`:
- Set the `version` field to `0.1.N` (this is the source of truth).
Update `server/Cargo.toml`:
- Set `version = "0.1.N"` in `[package]`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
@@ -58,32 +74,66 @@ Update `CHANGELOG.md`:
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
### M3. Commit the release
#### A3. Commit the release
Stage and commit `CHANGELOG.md`:
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
### M4. Tag the release
#### A4. Tag the release
```bash
git tag v0.1.N
```
### M5. Push
#### A5. Push
```bash
git push && git push --tags
```
### M6. Report
#### A6. Check for a planned sprint
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
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/plan-sprint`.
---
### Case B: No active sprint, but a planned sprint exists
A sprint is ready to activate. Verify it looks complete:
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
```bash
ls docs/sprints/sprint-N/
```
2. Check the ticket count:
```bash
db/connectors/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/plan-sprint` to complete planning.
If everything looks ready, activate the sprint:
```bash
db/connectors/sprint start
```
Then report:
- Sprint activated (name, ticket count per team)
- Remind the user to switch to a team branch and run `/start-sprint`
there (or `cd` into the relevant worktree)
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/plan-sprint`
to plan the next sprint.
---
+21
View File
@@ -6,6 +6,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.10] — 2026-02-19
### Added
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
### Changed
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
- World radial menu uses theme font instead of ThemeDB.fallback_font
### Fixed
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
## [v0.1.9] — 2026-02-18
### Fixed
+2 -1
View File
@@ -3,7 +3,8 @@
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
**Official Title:** The Settled Reach (D-021)
**Repository name:** commonwealth (historical code name, retained for path stability)
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
## Project Structure
+6 -1
View File
@@ -6,7 +6,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate
checklist-validate checklist-generate \
perf-baseline
# --- Configuration ---
@@ -44,6 +45,7 @@ help:
@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 " make perf-baseline Run performance benchmarks and save baseline"
@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)"
@@ -280,6 +282,9 @@ checklist-validate:
checklist-generate:
@tooling/validate-checklist
perf-baseline:
@tooling/perf-baseline
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
[gd_resource type="FontVariation" load_steps=2 format=3]
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
[resource]
base_font = ExtResource("1_base")
spacing_glyph = 1
spacing_space = 1
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Theme" load_steps=2 format=3]
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
[resource]
default_font = ExtResource("1_font")
default_font_size = 14
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Label/font_sizes/font_size = 14
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
RichTextLabel/font_sizes/normal_font_size = 14
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
Button/font_sizes/font_size = 14
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
LineEdit/font_sizes/font_size = 14
+10
View File
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
theme/custom_font="res://assets/fonts/michroma_ui.tres"
[display]
window/size/viewport_width=1920
@@ -111,6 +116,11 @@ bug_report={
"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)
]
}
teleport_hub={
"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":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+7 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=20 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"]
@@ -17,7 +17,8 @@
[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"]
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -136,6 +137,9 @@ layer = 20
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
; #503: Auto-checklist overlay — condition progress in gauntlet mode
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -149,4 +153,4 @@ script = ExtResource("10_cursor")
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
+123 -12
View File
@@ -3,7 +3,11 @@ extends Node
# Semantic actions — NO raw key codes cross the bridge
# Movement uses hold-to-move (polled each frame in _process).
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
# Composite diagonals: holding W+D simultaneously → northeast.
#
# D-054: Mouse-relative facing and movement.
# Mouse position determines facing direction (client-side float).
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
# Server receives facing octant only — the full float stays client-side.
#
# Movement throttle: client-side rate limit per stance (D-053).
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
@@ -15,10 +19,18 @@ enum Action {
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
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
@@ -31,27 +43,44 @@ var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
return
var dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
dir.y -= 1
if Input.is_action_pressed("move_south"):
dir.y += 1
if Input.is_action_pressed("move_east"):
dir.x += 1
if Input.is_action_pressed("move_west"):
dir.x -= 1
if dir != Vector2i.ZERO:
# D-054: Send facing octant to server when it changes (even without movement)
if facing_octant != _last_sent_octant:
_last_sent_octant = facing_octant
input_queue.append({
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
})
# Poll held WASD keys
var raw_dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
raw_dir.y -= 1
if Input.is_action_pressed("move_south"):
raw_dir.y += 1
if Input.is_action_pressed("move_east"):
raw_dir.x += 1
if Input.is_action_pressed("move_west"):
raw_dir.x -= 1
if raw_dir != Vector2i.ZERO:
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec >= interval:
_last_move_msec = now
var action: Action = _dir_to_action(dir)
# D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir)
input_queue.append({
"action": action,
"timestamp_msec": now,
@@ -77,6 +106,9 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
if action != -1:
input_queue.append({
@@ -92,6 +124,85 @@ func flush_queue() -> Array[Dictionary]:
return queue
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0
facing_octant = "North"
_last_sent_octant = "North"
# D-054: Compute facing angle from mouse position relative to player screen position.
# Uses viewport canvas transform to convert world coords to screen coords.
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
var vp := get_viewport()
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var player_world_px := GameState.player_position * Constants.TILE_SIZE
var player_screen := canvas_xf * player_world_px
var mouse_screen := vp.get_mouse_position()
var delta := mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
facing_octant = _angle_to_octant(facing_angle)
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
# Forward = facing_angle direction. Output: nearest octant direction vector.
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
# Build a continuous direction vector relative to facing.
# raw_dir.y: -1 = W (forward), +1 = S (backward)
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
var forward := Vector2(cos(facing_angle), sin(facing_angle))
var right := Vector2(-forward.y, forward.x) # 90° clockwise
# Combine: forward/back from W/S, strafe from A/D
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
# Snap to nearest octant direction
return _snap_to_octant_dir(world_float)
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
if dir.length_squared() < 0.001:
return Vector2i.ZERO
var angle := dir.angle()
# Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return Vector2i(1, 0) # East
1: return Vector2i(1, 1) # Southeast
2, -6: return Vector2i(0, 1) # South
3, -5: return Vector2i(-1, 1) # Southwest
4, -4: return Vector2i(-1, 0) # West
-3, 5: return Vector2i(-1, -1) # Northwest
-2: return Vector2i(0, -1) # North
-1: return Vector2i(1, -1) # Northeast
_: return Vector2i.ZERO
# D-054: Convert a facing angle (radians) to the nearest octant name.
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return "East"
1: return "Southeast"
2, -6: return "South"
3, -5: return "Southwest"
4, -4: return "West"
-3, 5: return "Northwest"
-2: return "North"
-1: return "Northeast"
_: return "East"
# Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action:
+25 -2
View File
@@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -177,7 +181,16 @@ func send_input(player_input: Dictionary) -> Error:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
if not wire_name.is_empty():
_test_input_queue.append(wire_name)
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
_test_facing = facing
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
@@ -258,6 +271,10 @@ static func _action_enum_to_wire(action: int) -> String:
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -273,6 +290,11 @@ func _test_snapshot() -> Dictionary:
# Process queued inputs
for action_name in _test_input_queue:
if action_name == "TeleportToHub":
# #501: Reset to hub spawn position, clear dialogue
_test_player_pos = Vector2i(10, 10)
_test_in_dialogue = false
continue
if action_name == "Interact":
# Mock dialogue trigger (#434): if near NPC, start dialogue
var npc_pos := Vector2i(12, 9)
@@ -285,7 +307,6 @@ func _test_snapshot() -> Dictionary:
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
@@ -316,6 +337,7 @@ func _test_snapshot() -> Dictionary:
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": _test_npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
@@ -394,6 +416,7 @@ func _test_snapshot() -> Dictionary:
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
@@ -0,0 +1,354 @@
extends RefCounted
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
## checklist YAML conditions and latches satisfied conditions.
##
## Usage:
## var evaluator := ChecklistEvaluator.new()
## evaluator.load_room("inventory_warehouse")
## evaluator.evaluate() # call each tick
## var results := evaluator.get_results()
##
## Condition types (per checklist.schema.json):
## player_near, player_facing, entity_present, entity_absent,
## expected_monologue, expected_dialogue, expected_interaction_verb
##
## Spec ref: D-030 (testability), checklist.schema.json (#497).
var _room_conditions: Array = [] # Conditions from per-room checklist
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
var _current_room_id: String = ""
var _content_base: String = "" # Absolute path to content/ directory
var _loaded: bool = false
func _init() -> void:
# Content directory lives at repo root (content/), one level above the Godot
# project (client/). In editor/dev mode we resolve via the project path.
# In exported builds, content is expected at res://content/ (copied by export
# preset) — the globalize fallback won't exist, so check res:// first.
if DirAccess.dir_exists_absolute("res://content"):
_content_base = ProjectSettings.globalize_path("res://content")
else:
var project_path := ProjectSettings.globalize_path("res://")
_content_base = project_path.path_join("../content")
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
func load_room(room_id: String) -> void:
if room_id == _current_room_id and _loaded:
return
_current_room_id = room_id
_room_conditions.clear()
# Clear per-room latches (keep cross-room latches)
var cross_ids := {}
for cond in _cross_conditions:
cross_ids[cond.get("id", "")] = true
var kept := {}
for cid in _latched:
if cross_ids.has(cid):
kept[cid] = true
_latched = kept
# Load per-room checklist
var room_path := _content_base.path_join(
"gauntlet/rooms/%s/checklist.yaml" % room_id)
var room_data := _load_checklist_file(room_path)
if room_data.has("conditions"):
_room_conditions = room_data["conditions"]
_warn_empty_ids(_room_conditions, room_path)
# Load cross-room checks (only on first load)
if _cross_conditions.is_empty():
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
var cross_data := _load_checklist_file(cross_path)
if cross_data.has("conditions"):
_cross_conditions = cross_data["conditions"]
_warn_empty_ids(_cross_conditions, cross_path)
_loaded = true
## Evaluate all conditions against current GameState. Latches newly met conditions.
func evaluate() -> void:
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty() or _latched.has(cid):
continue
if _evaluate_condition(cond):
_latched[cid] = true
## Returns array of {id, description, met} for all loaded conditions.
## Conditions with empty id are excluded (invalid, cannot be latched).
func get_results() -> Array:
var results: Array = []
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty():
continue
results.append({
"id": cid,
"description": cond.get("description", ""),
"condition_type": cond.get("condition_type", ""),
"met": _latched.has(cid),
})
return results
## Total number of loaded conditions (excludes conditions with empty id).
func get_total_count() -> int:
var count: int = 0
for cond in _room_conditions + _cross_conditions:
if not cond.get("id", "").is_empty():
count += 1
return count
## Number of latched (met) conditions.
func get_met_count() -> int:
return _latched.size()
## Whether all conditions are met.
func is_complete() -> bool:
return get_met_count() >= get_total_count() and get_total_count() > 0
## Whether any checklist is loaded.
func is_loaded() -> bool:
return _loaded
## Reset all state (room change to null, or disconnect).
func reset() -> void:
_room_conditions.clear()
_cross_conditions.clear()
_latched.clear()
_current_room_id = ""
_loaded = false
static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size():
if conditions[i].get("id", "").is_empty():
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
# -- Condition evaluation ------------------------------------------------------
func _evaluate_condition(cond: Dictionary) -> bool:
match cond.get("condition_type", ""):
"player_near":
return _eval_player_near(cond)
"player_facing":
return _eval_player_facing(cond)
"entity_present":
return _eval_entity_present(cond)
"entity_absent":
return _eval_entity_absent(cond)
"expected_monologue":
return _eval_expected_monologue(cond)
"expected_dialogue":
return _eval_expected_dialogue(cond)
"expected_interaction_verb":
return _eval_expected_interaction_verb(cond)
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
return false
## x/y and radius are in tile coordinates (matching GameState.player_position),
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
## happens only at render time.
func _eval_player_near(cond: Dictionary) -> bool:
var tx: float = float(cond.get("x", 0))
var ty: float = float(cond.get("y", 0))
var radius: float = float(cond.get("radius", 0.0))
var target := Vector2(tx, ty)
return GameState.player_position.distance_to(target) <= radius
func _eval_player_facing(cond: Dictionary) -> bool:
var direction: String = str(cond.get("direction", ""))
# Schema uses 4-cardinal (North/South/East/West).
# GameState uses 8-directional. Exact match only.
return GameState.player_facing == direction
func _eval_entity_present(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return _find_entity(entity_id)
func _eval_entity_absent(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return not _find_entity(entity_id)
func _eval_expected_monologue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_monologue == null:
return false
var text: String = str(GameState.current_monologue.get("text", ""))
return text.find(contains) >= 0
func _eval_expected_dialogue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_dialogue == null:
return false
var text: String = str(GameState.current_dialogue.get("speech", ""))
return text.find(contains) >= 0
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
var verb: String = str(cond.get("verb", ""))
for interaction in GameState.nearby_interactions:
if not interaction is Dictionary:
continue
if int(interaction.get("entity_id", -1)) != entity_id:
continue
var verbs: Array = interaction.get("verbs", [])
for v in verbs:
if not v is Dictionary:
continue
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
if v.get("available", true):
return true
return false
# -- Helpers -------------------------------------------------------------------
func _find_entity(entity_id: int) -> bool:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if int(entity.get("entity_id", -1)) == entity_id:
return true
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("ChecklistEvaluator: cannot open %s" % path)
return {}
var text := file.get_as_text()
file.close()
return parse_checklist_yaml(text)
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
+18 -2
View File
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Npc": return ENTITY_COLOR_UNKNOWN
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
@@ -80,3 +89,10 @@ const PERIPHERAL_ALPHA: float = 0.5
# Facing direction indicator
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
+78 -2
View File
@@ -13,12 +13,15 @@ extends Node2D
@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 checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -60,8 +63,13 @@ func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
var old_pos := GameState.player_position
GameState.apply_snapshot(snapshot)
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
@@ -98,6 +106,10 @@ func _process(_delta: float) -> void:
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -112,9 +124,15 @@ func _process(_delta: float) -> void:
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
# #501: Skip re-enable during teleport — _teleport_transition() disables
# smoothing for a clean camera snap. Defer by one frame to avoid the
# re-enable block in the same _process() call undoing the snap.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
if _teleport_in_progress:
_teleport_in_progress = false
else:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
@@ -159,6 +177,10 @@ func _consume_monologue() -> void:
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
if mono_id.begins_with("room_reset"):
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
GameState.current_monologue = null
@@ -218,3 +240,57 @@ func _on_dialogue_dismissed() -> void:
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()
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Snap camera: disable smoothing, force re-anchor.
# _teleport_in_progress defers smoothing re-enable by one frame so the
# re-enable block at the bottom of _process() doesn't undo the snap.
camera.position_smoothing_enabled = false
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
_teleport_in_progress = true
# Clear client-side buffers
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black: instant black overlay, fades to transparent over 0.3s
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(0, 0, 0, 1.0)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
tween.tween_callback(_flash_rect.queue_free)
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
tween.tween_callback(_flash_rect.queue_free)
+46 -17
View File
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
func _ready() -> void:
print("EntityRenderer: Initialized")
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
finished_ids.append(entity_id)
continue
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
_entity_tweens.erase(eid)
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
# D-033 color by entity kind (Phase 1 default)
# TODO(#361): derive from RelationshipState via knowledge graph
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
# Add facing indicator for the player entity
if entity_id == GameState.player_entity_id:
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
var visibility: Variant = entity_data.get("visibility")
@@ -119,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
# v2: Update facing indicator rotation (player entity only)
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
if indicator != null:
indicator.rotation = _facing_to_rotation(GameState.player_facing)
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
@@ -134,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
entity_node.queue_free()
entity_nodes.erase(entity_id)
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
@@ -155,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
parent_node.add_child(indicator)
# Convert facing direction string to rotation in radians (0 = North/up)
static func _facing_to_rotation(facing: String) -> float:
match facing:
"North": return 0.0
"Northeast": return PI / 4.0
"East": return PI / 2.0
"Southeast": return 3.0 * PI / 4.0
"South": return PI
"Southwest": return 5.0 * PI / 4.0
"West": return 3.0 * PI / 2.0
"Northwest": return 7.0 * PI / 4.0
_: return 0.0
+11 -7
View File
@@ -5,14 +5,15 @@ extends TileMapLayer
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
#
# Tile types (atlas coords in the programmatic source):
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (4,0) = reset_plate — amber (#502)
const TILE_SIZE: int = Constants.TILE_SIZE
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
# Wire-format string to TileType mapping
const TILE_TYPE_MAP: Dictionary = {
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
"wall": TileType.WALL,
"door": TileType.DOOR,
"object": TileType.OBJECT,
"reset_plate": TileType.RESET_PLATE,
}
var _initialized: bool = false
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
# Create an atlas source backed by a programmatic image
var source := TileSetAtlasSource.new()
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
# Floor (0,0) — dark gray
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
# Object (3,0) — teal
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
# Reset plate (4,0) — amber (#502)
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
var tex := ImageTexture.create_from_image(img)
source.texture = tex
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
# Create tile entries in the atlas
for i in range(4):
for i in range(5):
source.create_tile(Vector2i(i, 0))
var source_id := ts.add_source(source)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+692
View File
@@ -0,0 +1,692 @@
## #503: Auto-checklist progress tracking — unit + integration tests.
##
## Tests cover:
## 1. YAML parser: basic types, conditions array, edge cases
## 2. Condition evaluation: all 7 condition types
## 3. Latching: conditions stay met once satisfied
## 4. Room change: per-room conditions reset, cross-room conditions persist
## 5. Overlay: visibility gating on gauntlet_mode
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
##
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
class_name TestChecklist
extends GdUnitTestSuite
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.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.nearby_interactions = []
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.player_inventory = []
# -- YAML Parser Tests ---------------------------------------------------------
func test_parse_empty_yaml() -> void:
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
assert_that(result.size()).is_equal(0)
func test_parse_top_level_string() -> void:
var yaml := "room_id: inventory_warehouse"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
func test_parse_top_level_quoted_string() -> void:
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
func test_parse_single_condition() -> void:
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.has("conditions")).is_true()
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["id"]).is_equal("test-1")
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
assert_that(conditions[0]["x"]).is_equal(10)
assert_that(conditions[0]["y"]).is_equal(20)
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
func test_parse_multiple_conditions() -> void:
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(2)
assert_that(conditions[0]["id"]).is_equal("cond-a")
assert_that(conditions[1]["id"]).is_equal("cond-b")
assert_that(conditions[1]["direction"]).is_equal("East")
func test_parse_comments_ignored() -> void:
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["entity_id"]).is_equal(5)
func test_parse_integer_and_float_values() -> void:
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var cond: Dictionary = result["conditions"][0]
assert_that(cond["x"]).is_equal(42)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
func test_parse_scope_field() -> void:
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("scope")).is_equal("cross_room")
func test_parse_inline_comment_stripped() -> void:
var yaml := "room_id: test # this is a comment"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
# -- Condition Evaluation Tests ------------------------------------------------
func _make_evaluator(conditions: Array):
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = conditions
evaluator._loaded = true
return evaluator
func test_eval_player_near_within_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-1", "condition_type": "player_near",
"x": 10, "y": 21, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
).is_equal(1)
func test_eval_player_near_outside_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-2", "condition_type": "player_near",
"x": 10, "y": 30, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
).is_equal(0)
func test_eval_player_near_exact_boundary() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-3", "condition_type": "player_near",
"x": 10, "y": 22, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: distance exactly equals radius — should be met (<=)"
).is_equal(1)
func test_eval_player_facing_match() -> void:
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "face-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_player_facing_no_match() -> void:
GameState.player_facing = "North"
var evaluator = _make_evaluator([{
"id": "face-2", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_player_facing_diagonal_no_match() -> void:
# 8-directional facing "Northeast" should NOT match "East" or "North"
GameState.player_facing = "Northeast"
var evaluator = _make_evaluator([{
"id": "face-diag", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_facing: Northeast should NOT match East (exact match only)"
).is_equal(0)
func test_eval_entity_present_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
]
var evaluator = _make_evaluator([{
"id": "present-1", "condition_type": "entity_present",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_entity_present_not_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "present-2", "condition_type": "entity_present",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_entity_absent_when_not_visible() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-1", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 99 not in visible_entities — should be met"
).is_equal(1)
func test_eval_entity_absent_when_visible() -> void:
GameState.visible_entities = [
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-2", "condition_type": "entity_absent",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 10 IS visible — should NOT be met"
).is_equal(0)
func test_eval_expected_monologue_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-1", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_monologue_no_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-2", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_monologue_null() -> void:
GameState.current_monologue = null
var evaluator = _make_evaluator([{
"id": "mono-3", "condition_type": "expected_monologue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_monologue: null monologue should not match"
).is_equal(0)
func test_eval_expected_dialogue_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-1", "condition_type": "expected_dialogue",
"contains": "Who are you",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_dialogue_no_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-2", "condition_type": "expected_dialogue",
"contains": "Goodbye",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_dialogue_null() -> void:
GameState.current_dialogue = null
var evaluator = _make_evaluator([{
"id": "dlg-3", "condition_type": "expected_dialogue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_dialogue: null dialogue should not match"
).is_equal(0)
func test_eval_interaction_verb_match() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-1", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_interaction_verb_by_kind() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-kind", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Take",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: should match by kind='Take' as well as label"
).is_equal(1)
func test_eval_interaction_verb_wrong_entity() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
}]
var evaluator = _make_evaluator([{
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
"entity_id": 99, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: wrong entity_id should not match"
).is_equal(0)
func test_eval_interaction_verb_unavailable() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
}]
var evaluator = _make_evaluator([{
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: unavailable verb should not match"
).is_equal(0)
func test_eval_interaction_verb_no_interactions() -> void:
GameState.nearby_interactions = []
var evaluator = _make_evaluator([{
"id": "verb-none", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# -- Latching Tests ------------------------------------------------------------
func test_latching_condition_stays_met() -> void:
# Condition met on first evaluate, stays met even when state changes.
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "latch-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Change state so condition would be false if re-evaluated fresh
GameState.player_facing = "North"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Latched condition should stay met even after state changes"
).is_equal(1)
func test_latching_monologue_transient() -> void:
# Monologue appears for one tick, then disappears. Condition should latch.
var evaluator = _make_evaluator([{
"id": "mono-latch", "condition_type": "expected_monologue",
"contains": "recalibrated",
}])
# Tick 1: no monologue
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# Tick 2: monologue fires
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Tick 3: monologue consumed (null)
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Monologue condition should stay latched after monologue disappears"
).is_equal(1)
func test_multiple_conditions_partial_latching() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
])
# Tick 1: only facing matches
GameState.player_facing = "East"
GameState.visible_entities = []
GameState.player_position = Vector2(0, 0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(3)
# Tick 2: entity also visible
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
# Tick 3: player moves to target
GameState.player_position = Vector2(50.0, 50.0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(3)
assert_that(evaluator.is_complete()).is_true()
# -- Room Change Tests ---------------------------------------------------------
func test_room_change_resets_per_room_conditions() -> void:
var evaluator = ChecklistEvaluatorScript.new()
# Manually set conditions to avoid file loading
evaluator._room_conditions = [
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Simulate room change by loading a new "room"
evaluator._current_room_id = "old_room"
evaluator._room_conditions = [
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
]
# Clear latches for the new room (simulating load_room behavior)
evaluator._latched.clear()
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"After room change, old latches should be cleared; new condition not met"
).is_equal(0)
func test_reset_clears_all_state() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
])
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
evaluator.reset()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.get_total_count()).is_equal(0)
# -- get_results Tests ---------------------------------------------------------
func test_get_results_structure() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results.size()).is_equal(1)
assert_that(results[0]["id"]).is_equal("c1")
assert_that(results[0]["description"]).is_equal("Test condition")
assert_that(results[0]["condition_type"]).is_equal("player_facing")
assert_that(results[0]["met"]).is_true()
func test_get_results_unmet() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results[0]["met"]).is_false()
# -- Overlay Visibility Tests --------------------------------------------------
func _make_checklist_overlay() -> Control:
var overlay = Control.new()
overlay.set_script(ChecklistOverlayScript)
auto_free(overlay)
add_child(overlay)
return overlay
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be hidden in non-gauntlet mode"
).is_false()
func test_overlay_visible_in_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be visible in gauntlet mode"
).is_true()
func test_overlay_hides_on_gauntlet_deactivation() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).is_true()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Overlay must hide when gauntlet mode deactivates"
).is_false()
func test_overlay_evaluator_accessible() -> void:
var overlay := _make_checklist_overlay()
var evaluator = overlay.get_evaluator()
assert_that(evaluator).override_failure_message(
"Overlay should expose evaluator via get_evaluator()"
).is_not_null()
func test_overlay_in_main_scene() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
var instance: Node = scene.instantiate()
auto_free(instance)
add_child(instance)
instance._process(0.016)
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
assert_that(overlay).override_failure_message(
"ChecklistOverlay node should exist in main scene tree"
).is_not_null()
if overlay is CanvasItem:
assert_that((overlay as CanvasItem).visible).override_failure_message(
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
).is_false()
# -- Integration: Snapshot -> Evaluation ----------------------------------------
func test_integration_snapshot_to_evaluator() -> void:
# Integration test: GameState snapshot data -> evaluator -> correct results.
# Tests the evaluator directly (overlay wiring tested separately).
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = [
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
# Simulate gauntlet snapshot with player entity
GameState.visible_entities = [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
]
GameState.player_facing = "North"
evaluator.evaluate()
# Entity present should be met, facing should not
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(2)
# Change facing — second condition should also latch
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
assert_that(evaluator.is_complete()).is_true()
# Verify results array contains both conditions as met
var results: Array = evaluator.get_results()
for r in results:
assert_that(r["met"]).override_failure_message(
"Condition '%s' should be met after snapshot sequence" % r["id"]
).is_true()
# -- Edge Cases ----------------------------------------------------------------
func test_empty_entity_list_entity_present() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-empty", "condition_type": "entity_present",
"entity_id": 0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_empty_entity_list_entity_absent() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-absent-empty", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent with empty visible_entities should be met"
).is_equal(1)
func test_zero_radius_player_near() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "edge-zero-radius", "condition_type": "player_near",
"x": 10, "y": 20, "radius": 0.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near with radius 0 at exact position should be met"
).is_equal(1)
func test_unknown_condition_type() -> void:
var evaluator = _make_evaluator([{
"id": "edge-unknown", "condition_type": "nonexistent_type",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Unknown condition type should not be met"
).is_equal(0)
func test_no_conditions_loaded() -> void:
var evaluator = ChecklistEvaluatorScript.new()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_total_count()).is_equal(0)
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.is_complete()).is_false()
# -- 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
+14 -12
View File
@@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void:
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_player_facing() -> void:
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
# Indicator rotation = facing_angle + PI/2 (0=North basis).
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,
# {facing_angle → expected indicator rotation}
var angles := {
-PI / 2.0: 0.0, # North
0.0: PI / 2.0, # East
PI / 2.0: PI, # South
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
}
for dir in expected:
GameState.player_facing = dir
for angle in angles:
InputMapper.facing_angle = angle
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)
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
+366
View File
@@ -0,0 +1,366 @@
## #521: Confrontation D-033 color shift — QA test suite
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
## Sprint Completion Proof (joint.md):
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
## - Cursor hover tint also updates to match relationship
## - Palette matches D-033 exactly
##
## Tests are structured in layers:
## 1. D-033 color palette constants — always pass (no implementation dependency)
## 2. Relationship-to-color mapping — tests the lookup function
## 3. Entity renderer relationship coloring — tests that entities USE relationship
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
## 5. Protocol/GameState passthrough — tests data pipeline integrity
class_name TestColorShift
extends GdUnitTestSuite
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
# -- Test data -----------------------------------------------------------------
# Entity with relationship field (v4 protocol format)
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
return {
"entity_id": entity_id,
"x": x, "y": y, "z": 0,
"kind": {"variant": kind, "data": null},
"visibility": "Forward",
"relationship": relationship,
"observation": "Visible",
}
func _make_entity_renderer() -> Node2D:
var renderer: Node2D = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func before_test() -> void:
GameState.player_entity_id = 1
# ==============================================================================
# Layer 1: D-033 Color Palette Constants
# These tests verify the palette is defined correctly. No implementation needed.
# ==============================================================================
func test_d033_unknown_teal() -> void:
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
func test_d033_friendly_green() -> void:
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
func test_d033_poi_amber() -> void:
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d033_hostile_red() -> void:
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d033_object_grey() -> void:
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
func test_d033_player_cool_white() -> void:
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
func test_d033_palette_all_distinct() -> void:
# All 6 D-033 colors must be distinct from each other
var colors: Array[Color] = [
Constants.ENTITY_COLOR_UNKNOWN,
Constants.ENTITY_COLOR_FRIENDLY,
Constants.ENTITY_COLOR_POI,
Constants.ENTITY_COLOR_HOSTILE,
Constants.ENTITY_COLOR_OBJECT,
Constants.ENTITY_COLOR_PLAYER,
]
for i in range(colors.size()):
for j in range(i + 1, colors.size()):
assert_that(colors[i] != colors[j]).is_true()
# ==============================================================================
# Layer 2: Relationship-to-Color Mapping
# Tests the lookup function that maps relationship strings to D-033 colors.
# Depends on #521 adding color_for_relationship() to Constants.
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
# ==============================================================================
func _has_color_for_relationship() -> bool:
# Check if the Constants script has a color_for_relationship method.
for method in ConstantsScript.get_script_method_list():
if method.name == "color_for_relationship":
return true
return false
func test_relationship_color_unknown() -> void:
if not _has_color_for_relationship():
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
return
var color: Color = Constants.color_for_relationship("Unknown")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_color_friendly() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Friendly")
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_color_person_of_interest() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("PersonOfInterest")
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_color_hostile() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Hostile")
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_color_fallback() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("SomethingWeird")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# ==============================================================================
# Layer 3: Entity Renderer — Relationship-Based Coloring
# Tests that entity_renderer uses the relationship field for NPC colors.
# Player and Object entities should remain unaffected by relationship field.
# ==============================================================================
func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
# Object entities always use ENTITY_COLOR_OBJECT
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
# ==============================================================================
# Layer 4: Tween on Relationship Change
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
# D-063: "entity D-033 color may fade" on confrontation delivery.
# ==============================================================================
func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
func test_color_shift_reaches_target() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Simulate time passing: ~1.5 seconds of frames
var elapsed: float = 0.0
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
# The second change should preempt the first tween and converge to Hostile.
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
# First change: Unknown → Friendly
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
# Advance partway (0.1s of a 0.5s tween)
for i in range(6):
renderer._process(1.0 / 60.0)
# Second change mid-tween: Friendly → Hostile (preempts first)
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Advance past full duration
var elapsed: float = 0.0
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_same_relationship_no_tween() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
renderer.update_entities(entities)
var color_second: Color = node.color
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
# ==============================================================================
# Layer 5: Protocol — Relationship Field Round-Trip
# ==============================================================================
func test_protocol_entity_relationship_decoded() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": "PersonOfInterest",
"visibility": "Forward",
"observation": "Visible",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("PersonOfInterest")
func test_protocol_entity_relationship_defaults_unknown() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("Unknown")
func test_protocol_entity_all_relationship_values() -> void:
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
for rel in relationships:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": rel,
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal(rel)
# ==============================================================================
# Layer 6: GameState — Relationship Data Passthrough
# ==============================================================================
func test_game_state_preserves_relationship() -> void:
var snapshot: Dictionary = {
"tick": 1,
"entities": [_make_entity(2, "Npc", "Friendly")],
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.visible_entities.size()).is_equal(1)
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
func test_game_state_relationship_changes_between_snapshots() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
# ==============================================================================
# Helpers
# ==============================================================================
func _entity_uses_relationship(renderer: Node2D) -> bool:
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
func _renderer_has_tween_support(renderer: Node2D) -> bool:
return renderer.get("_entity_tweens") != null
+288
View File
@@ -0,0 +1,288 @@
## #501: Hub teleport client UX — QA test suite
## Spec refs: D-020 (protocol), D-030 (testability)
## Sprint Completion Proof (joint.md):
## - Home key sends TeleportToHub in Gauntlet mode
## - 0.3s fade-to-black-and-back plays on teleport
## - Dialogue/monologue/interaction buffer cleared on teleport
## - Non-Gauntlet: action rejected, client shows no effect
class_name TestHubTeleport
extends GdUnitTestSuite
# -- Fixtures ------------------------------------------------------------------
var _gauntlet_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
"gauntlet_mode": true,
"room_id": "proof_room",
}
var _normal_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
}
func before_test() -> void:
GameState.gauntlet_mode = false
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.room_id = null
InputMapper.input_queue.clear()
SimBridge.reset_test_state()
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
func test_teleport_hub_action_exists() -> void:
# Verify the enum value exists and is distinct
var action: int = InputMapper.Action.TELEPORT_HUB
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
# -- InputMapper: Gauntlet mode guard -----------------------------------------
func test_teleport_hub_blocked_outside_gauntlet() -> void:
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
GameState.gauntlet_mode = false
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_false()
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
GameState.gauntlet_mode = true
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_true()
# -- GameState: gauntlet_mode from snapshot ------------------------------------
func test_gauntlet_mode_set_from_snapshot() -> void:
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("proof_room")
func test_gauntlet_mode_false_when_absent() -> void:
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
assert_that(GameState.room_id).is_null()
func test_gauntlet_mode_transitions_off() -> void:
# Gauntlet on → off: mode should clear
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
# -- SimBridge: wire format encoding -------------------------------------------
func test_teleport_hub_wire_name() -> void:
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name).is_equal("TeleportToHub")
func test_teleport_hub_wire_not_empty() -> void:
# Wire name must not be empty (empty = client-only, not sent to server)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
func test_teleport_hub_encode_roundtrip() -> void:
# Verify MessagePack encode→decode roundtrip for TeleportToHub
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(42)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
assert_that(decoded.action.data).is_null()
# -- SimBridge: test mode teleport behavior ------------------------------------
func test_test_mode_teleport_resets_position() -> void:
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
SimBridge.reset_test_state()
# Move player away first
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Player should be back at hub spawn
var player_entity: Dictionary = snap.entities[0]
assert_that(player_entity.x).is_equal(10.0)
assert_that(player_entity.y).is_equal(10.0)
func test_test_mode_teleport_clears_dialogue() -> void:
# TeleportToHub in test mode should clear dialogue state
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
SimBridge._test_snapshot()
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Teleport detection -------------------------------------------------------
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
# These tests verify the distance math against that threshold.
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
func test_detect_teleport_large_jump() -> void:
# Position jump > threshold should be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(50.0, 50.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
func test_detect_teleport_normal_movement() -> void:
# Normal 1-tile movement should NOT be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_diagonal_movement() -> void:
# Diagonal movement (1,1) — distance ~1.41, not a teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 11.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_exactly_threshold() -> void:
# Exactly threshold — should NOT trigger (> not >=)
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_just_over() -> void:
# Just over threshold — should trigger
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.1, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
# -- Buffer clearing on teleport -----------------------------------------------
func test_teleport_clears_dialogue_in_test_mode() -> void:
# TeleportToHub in test mode should clear dialogue state via the pipeline
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Dialogue should be cleared by teleport
assert_that(SimBridge._test_in_dialogue).is_false()
assert_that(snap.current_dialogue).is_null()
func test_teleport_resets_position_in_test_mode() -> void:
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
var player: Dictionary = snap.entities[0]
assert_that(player.x).is_equal(10.0)
assert_that(player.y).is_equal(10.0)
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Send input integration (test mode) ----------------------------------------
func test_send_teleport_hub_in_test_mode() -> void:
# Verify send_input accepts TELEPORT_HUB in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
var err := SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(err).is_equal(OK)
func test_send_teleport_hub_queues_wire_action() -> void:
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
func test_gauntlet_mode_from_test_snapshot() -> void:
# Verify test snapshot includes gauntlet_mode field
SimBridge.reset_test_state()
SimBridge._test_gauntlet_mode = true
var snap: Dictionary = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_true()
SimBridge._test_gauntlet_mode = false
snap = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_false()
# -- Live mode outbound encoding -----------------------------------------------
func test_teleport_hub_outbound_entry() -> void:
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
# (We can't test full live mode in unit tests, but we test the encode path)
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
# Decode and verify
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
+119
View File
@@ -0,0 +1,119 @@
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
##
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
class_name TestInputMapperFacing
extends GdUnitTestSuite
# -- _angle_to_octant ----------------------------------------------------------
func test_angle_to_octant_east() -> void:
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
func test_angle_to_octant_north() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
func test_angle_to_octant_south() -> void:
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
func test_angle_to_octant_west() -> void:
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
func test_angle_to_octant_northeast() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
func test_angle_to_octant_southeast() -> void:
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
func test_angle_to_octant_southwest() -> void:
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
func test_angle_to_octant_northwest() -> void:
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
# -- _snap_to_octant_dir -------------------------------------------------------
func test_snap_east() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
func test_snap_north() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
func test_snap_south() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
func test_snap_west() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
func test_snap_northeast() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
func test_snap_southwest() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
func test_snap_zero_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
func test_snap_tiny_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
func test_snap_diagonal_bias() -> void:
# Slightly more east than north — should snap to northeast
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
# -- _wasd_to_world_dir --------------------------------------------------------
func test_wasd_forward_facing_east() -> void:
# W pressed, facing east → move east
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_forward_facing_north() -> void:
# W pressed, facing north → move north
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, -1))
func test_wasd_backward_facing_north() -> void:
# S pressed, facing north → move south
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_strafe_right_facing_north() -> void:
# D pressed, facing north → move east
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_strafe_left_facing_north() -> void:
# A pressed, facing north → move west
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
assert_object(result).is_equal(Vector2i(-1, 0))
func test_wasd_forward_facing_south() -> void:
# W pressed, facing south → move south
InputMapper.facing_angle = PI / 2.0 # South
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_diagonal_forward_right_facing_east() -> void:
# W+D pressed, facing east → move southeast
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
assert_object(result).is_equal(Vector2i(1, 1))
func test_wasd_strafe_right_facing_west() -> void:
# D pressed, facing west → move north
InputMapper.facing_angle = PI # West
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(0, -1))
func after_test() -> void:
InputMapper.reset_facing_state()
+17 -12
View File
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
renderer.queue_free()
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var directions := {
"North": 0.0,
"Northeast": PI / 4.0,
"East": PI / 2.0,
"Southeast": 3.0 * PI / 4.0,
"South": PI,
"Southwest": 5.0 * PI / 4.0,
"West": 3.0 * PI / 2.0,
"Northwest": 7.0 * PI / 4.0,
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
var indicator = player_node.get_node_or_null("FacingIndicator")
for dir_name in directions:
GameState.player_facing = dir_name
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(_test_entities_v2)
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
renderer.queue_free()
func test_entity_renderer_npc_has_no_facing_indicator() -> void:
+134
View File
@@ -0,0 +1,134 @@
extends Control
## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode.
## Renders below the GauntletHUD timer. Each condition shows a check/dash + description.
## Only visible in gauntlet_mode. Latched conditions stay checked.
##
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
const FONT_SIZE := 11
const LINE_HEIGHT := 16
const PADDING := Vector2(8, 6)
const MAX_DESC_CHARS := 52 # Truncate long descriptions
var _evaluator = null # ChecklistEvaluator instance
var _last_room_id: Variant = null
var _cached_font: Font = null # Cached to avoid per-frame theme lookup
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_evaluator = _ChecklistEvaluator.new()
_cached_font = get_theme_default_font()
func update_from_state() -> void:
if not GameState.gauntlet_mode:
if visible:
visible = false
return
if not visible:
visible = true
var room_id: Variant = GameState.room_id
if room_id == null:
if _evaluator.is_loaded():
_evaluator.reset()
_last_room_id = null
queue_redraw()
return
# Load checklist on room change
if room_id != _last_room_id:
_evaluator.load_room(str(room_id))
_last_room_id = room_id
# Evaluate conditions against current snapshot
_evaluator.evaluate()
queue_redraw()
func _draw() -> void:
if _evaluator == null or not _evaluator.is_loaded():
return
var font: Font = _cached_font if _cached_font else get_theme_default_font()
var results: Array = _evaluator.get_results()
if results.is_empty():
return
var met_count: int = _evaluator.get_met_count()
var total_count: int = _evaluator.get_total_count()
var all_complete: bool = _evaluator.is_complete()
# Header line: "CHECK: 5/8"
var header_text := "CHECK: %d/%d" % [met_count, total_count]
var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR
# Calculate box height: header + one line per condition + padding
var line_count: int = 1 + results.size()
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
# Calculate box width from longest line
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
for r in results:
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
if line_width > max_width:
max_width = line_width
var box_width: float = max_width + PADDING.x * 2
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
# Condition lines
for r in results:
y += LINE_HEIGHT
var is_met: bool = r.get("met", false)
var prefix: String = "[x] " if is_met else "[ ] "
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var color: Color = MET_COLOR if is_met else UNMET_COLOR
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
# -- Public API ---------------------------------------------------------------
func get_evaluator():
return _evaluator
func get_met_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_met_count()
func get_total_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_total_count()
func is_complete() -> bool:
if _evaluator == null:
return false
return _evaluator.is_complete()
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"]
; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned
[node name="ChecklistOverlay" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -420.0
offset_top = 78.0
offset_right = -16.0
offset_bottom = 400.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_checklist")
+2 -2
View File
@@ -16,8 +16,8 @@ const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
+34 -15
View File
@@ -4,7 +4,9 @@ extends Control
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
## Insert spoke sends PauseSimulation on activate (#518/D-058).
## Selecting any non-Insert spoke, cancelling, or pressing Escape calls
## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent.
signal spoke_selected(spoke_name: String)
@@ -35,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
var _cached_font: Font = null
func _ready() -> void:
@@ -43,6 +46,7 @@ func _ready() -> void:
size = custom_minimum_size
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
_cached_font = get_theme_default_font()
func _input(event: InputEvent) -> void:
@@ -83,6 +87,15 @@ func _close_menu() -> void:
visible = false
# Handle Escape key to dismiss menu and deactivate insert if active.
func _unhandled_input(event: InputEvent) -> void:
if _open and event.is_action_pressed("ui_cancel"):
if _insert_active:
deactivate_insert()
_close_menu()
get_viewport().set_input_as_handled()
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
@@ -115,29 +128,36 @@ func _confirm_selection() -> void:
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
elif _insert_active:
# Selecting any non-Insert spoke closes the insert and resumes sim
deactivate_insert()
elif _insert_active:
# No spoke selected (cancelled) while insert active — close insert
deactivate_insert()
_close_menu()
func _activate_insert() -> void:
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
# #518/D-058: Send PauseSimulation when insert opens. Idempotent —
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
# #518/D-058: Send ResumeSimulation when insert closes.
if _insert_active:
_insert_active = false
_send_pause()
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func _draw() -> void:
@@ -174,10 +194,9 @@ func _draw() -> void:
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
+12 -1
View File
@@ -152,6 +152,17 @@ Tracked questions awaiting discussion or resolution.
- **Assigned to:** Gestalt, Paula, Dudley
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1)
### Q-027: Fast-travel system design
- **Status:** Open
- **Question:** How does inter-system travel work in production gameplay? The current hub teleport (Home key, #501) is scoped as Gauntlet-only dev tool. Production travel must be diegetic and respect asymmetric information. Proposed flow: player goes to local gate → warps to system gate → interacts with target menu → jumps to destination system gate. Key constraints:
1. **Region gating:** fast-travel only available from safe or fast-travel-enabled regions. If you rented transport to reach a remote location (e.g. mountain colony), you must return the transport to civilization first — this can be a skip-travel interaction but must happen in-world.
2. **Asymmetric information:** NPCs observe arrivals and departures. Travel choices leak information (who saw you leave, who sees you arrive, what transport was used).
3. **Home key in production:** at most, Home could prompt "Do you want to fast-travel to the system hub?" if in a safe/enabled region — never instant teleport.
4. **Transport types:** walking, rented vehicle, public transit, gate network — each with different information exposure profiles.
- **Context:** #501 implemented instant Home key teleport gated behind `gauntlet_mode`. Re-scoped to Gauntlet-only after design review. Production fast-travel needs separate design and implementation.
- **Assigned to:** Gestalt, Paula, Tyre
- **Source:** Sprint 10 PR review discussion (2026-02-19)
---
*26 questions (3 resolved, 2 partially resolved, 21 open). Last updated: 2026-02-11*
*27 questions (3 resolved, 2 partially resolved, 22 open). Last updated: 2026-02-19*
+12
View File
@@ -7,4 +7,16 @@ Personal notes and random thoughts. Not acted upon unless explicitly instructed.
- Pipeline to create postcards for each world — visuals for in-game dossiers
- Investigate using Veo to create gate transition movies — based on planetary profiles, postcards, and game visual style
- Capture discussion with Gemini about setting up a 3D to 2D pipeline
- Improve asset generation pipeline: proper asset registry, status tracking per asset, prompt storage/versioning, pre-sprint prompt preparation pass to maintain visual/audio cohesion across batches
- Remote terminal proxy for Claude Code — mobile notification + interaction bridge for monitoring progress away from desk. Full feature scope:
- Capture permission prompts across all terminals/worktrees in a project
- Push notifications to phone (Ntfy/Gotify/Pushover/custom — self-hostable)
- Summary of last assistant message for context (what Claude is doing and why it's asking)
- Support AskUserQuestion mode: render multiple-choice options, free-text input
- Support permission approve/deny with the tool call details
- Basically a full terminal proxy that adapts to Claude Code's interactive elements
- Starting point: peon-ping already hooks into the right events (PermissionRequest, Notification, Stop, etc.) via ~/.claude/hooks/
- Hook event JSON includes: hook_event_name, notification_type, session_id, cwd, permission_mode
- Gap: hook event data may not include prompt content (tool call details, AskUser question text, multiple choice options) — investigate what's in the full event payload
- Architecture: extend peon-ping's send_notification pattern to push to a remote service, add a response channel back to the terminal
+83
View File
@@ -0,0 +1,83 @@
# Sprint 10: Prove — Audio Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `audio`
**Agents:** Inigo (sound design)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #327 | Audio asset set — 8 minimum viable files via Stable Audio Open | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-038 (audio in v0.1 — 8 files spec, hybrid generation approach)
- `decisions/perception.md` — D-067 (recognition chime fires at onset of cognitive delay), D-069 (audio dip profiles), D-071 (ListeningFocus boost), D-072 (universal event-driven conversation murmur)
- `decisions/architecture.md` — D-068 (5-bus audio architecture), D-073 (zone crossfade)
- `decisions/content.md` — D-074 (audio aesthetic identity — insert-tech vs organic)
---
## Notes
**#327 — Audio asset set — 8 minimum viable files**
- Existing: `AudioManager` autoload at `client/scripts/autoloads/audio_manager.gd` is fully implemented (D-068). The directory-scan registry pattern is in place — drop OGG files into `res://audio/` and they register automatically. Sprint 9 delivered 6 interaction SFX and 2 revised monologue chimes (#440, #453).
- What this ticket adds: The 8 ambient and world-SFX assets specified in D-038. These are distinct from the Sprint 9 interaction sounds — they cover the continuous soundscape and footstep system.
**The 8 files (D-038):**
| Key | File | Duration | Bus | Method |
|-----|------|----------|-----|--------|
| `amb_station_base` | `amb_station_base.ogg` | 60-90s | Ambient | SAO — station hum + span gate vibration |
| `amb_workplace_layer` | `amb_workplace_layer.ogg` | 45-60s | Ambient | SAO — cargo machinery |
| `amb_bar_layer` | `amb_bar_layer.ogg` | 45-60s | Ambient | SAO — conversation murmur baked in |
| `amb_corridor_layer` | `amb_corridor_layer.ogg` | 45-60s | Ambient | SAO — ventilation/subtle hum |
| `sfx_footstep_metal_walk` | `sfx_footstep_metal_walk.ogg` | 0.2-0.3s | WorldSFX | SAO or manual — one footstep, walk cadence |
| `sfx_footstep_metal_run` | `sfx_footstep_metal_run.ogg` | 0.2-0.3s | WorldSFX | SAO or manual — one footstep, run cadence |
| `sfx_monologue_chime` | `sfx_monologue_chime.ogg` | 0.5-1.0s | UISounds | Manual synthesis — crystalline, neural-lattice feel |
| `sfx_monologue_chime_urgent` | `sfx_monologue_chime_urgent.ogg` | 0.5-1.0s | UISounds | Manual synthesis — sharper, contradiction/anomaly variant |
**D-074 aesthetic split — critical:**
- Ambient layers and footsteps = **organic**: warm, breathy, environmental reverb, natural decay. Use SAO for these.
- Monologue chimes = **insert-tech**: synthetic, precise, clinical, no reverb. Manually synthesize — SAO will produce too organic a character for chimes.
- The Sprint 9 chimes (#453) are acknowledged placeholders. Sprint 10 chimes are the production-quality replacements.
**D-038 amendment (hybrid approach):**
- SAO ceiling is ~47s. Accept 45s loops with crossfade for ambient layers (the `play_loop` method in AudioManager handles this).
- Sounds <200ms with precise/digital character: manual synthesis only.
**Zone crossfade integration:**
- `AudioManager.set_zone()` is currently a stub (D-073). This sprint's audio assets enable testing the stub's behavior. Inigo should verify that `amb_station_base`, `amb_workplace_layer`, `amb_bar_layer`, and `amb_corridor_layer` all loop cleanly with no audible seam — AudioManager's `_enable_loop` will set `loop = true` on OGG streams automatically.
- Full zone crossfade logic (the tween between ambient layers on zone change) is NOT in scope for this sprint — it remains a stub. The assets just need to be present and looping.
**Delivery:**
- Commit all 8 OGG files to `audio` branch under `client/audio/` (which maps to `res://audio/` in Godot).
- AudioManager registers them at startup via directory scan. No code changes to client branch.
- Effort: 3-4d (generation + iteration + mastering).
---
## Dependency Chain
```
#327 (8 audio assets) — standalone; AudioManager stub (D-068) already in client branch
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(audio): sprint 10 prove — ambient + world sfx assets" --description "body" --base main --head audio
```
+68
View File
@@ -0,0 +1,68 @@
# Sprint 10: Prove — CI Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `ci`
**Agents:** Justine (build/deploy), Hoshe (QA review)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #499 | Performance baseline tooling (make perf-baseline) | #487 (done) |
| #510 | CI pipeline (Gitea Actions YAML, 3-tier) | — |
| #515 | Bidirectional relationship consistency warning (content validation check 9) | #464 (done) |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-030 (testing architecture phases), D-026 (100ms tick budget)
- `decisions/scope.md` — D-038 (audio in v0.1 — audio asset presence/absence affects `make ci` behavior)
---
## Notes
**#499 — Performance baseline tooling**
- Existing: `Makefile` has `make pre-pr`, `make test-server`, `make test-client` targets. The test infrastructure (Sprint 9) is complete. No `perf-baseline` target exists yet.
- Deliver: `make perf-baseline` target. Runs the server benchmark suite, captures tick timing, memory usage, and entity count scaling metrics, outputs results to `tests/perf/`. The baseline file should be committed and tracked in git so future runs can detect regressions.
- Integration: Server #500 (content scaling test) feeds into this — coordinate timing so #499 can include the content scaling numbers in the baseline output. If #500 isn't merged, the perf baseline can run with existing content and be updated when #500 lands.
**#510 — CI pipeline (Gitea Actions YAML, 3-tier)**
- Existing: `make pre-pr` exists and is the manual gate. Gitea Actions self-hosted runner is available.
- Deliver: A Gitea Actions workflow YAML (`/.gitea/workflows/ci.yaml` or `.gitea/workflows/`) implementing three tiers:
- Commit tier: `<2min` — lint only (`make lint-server`, `make lint-client`)
- PR merge gate: `<15min` — wraps `make pre-pr`
- Nightly: `<30min` — full build + all tests + content validation
- **Deferred condition**: This ticket is backlog-tagged "deferred until lead greenlights." Confirm with Team Leader before starting. If greenlit, coordinate with Tyre on self-hosted runner configuration.
- Integration: Wraps existing `make` targets — should require zero changes to those targets.
**#515 — Bidirectional relationship consistency warning**
- Existing: Content validation runs via `tooling/validate-content`. Phase 1-2 checks are complete (#464 done). Check 9 is advisory.
- Deliver: Advisory warning (non-blocking, not an error) when NPC A has a relationship to NPC B but no reciprocal entry exists in NPC B's relationships. Output format: `WARN: NPC 'kael' has relationship to 'sera' but 'sera' has no reciprocal entry.` Effort: 0.25d.
- Non-obvious: One-sided relationships are sometimes intentional (faction member knows of faction leader, not vice versa). The warning is advisory, never a hard failure.
---
## Dependency Chain
```
#499 (perf baseline) — standalone (server #487 done); benefits from server #500 merging first
#510 (CI pipeline) — standalone; greenlight from lead required before start
#515 (bidirectional warning) — standalone (#464 done)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): sprint 10 prove — ci" --description "body" --base main --head ci
```
+109
View File
@@ -0,0 +1,109 @@
# Sprint 10: Prove — Client Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `client`
**Agents:** Stig (UI/client dev), Tyre (arch review), Hoshe (QA)
---
## Carry-over from Sprint 9
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #501 | Hub teleport client UX (Home key, fade transition) | backlog | Targeted Sprint 9, slipped — blocked by #491 server work which also slipped. Unblocks immediately once server #491 ships. |
| #502 | Room reset client UX (reset plate tile, interaction verb, amber flash) | backlog | Targeted Sprint 9, slipped. Unblocked (#490 done). |
| #503 | Auto-checklist progress tracking (client-side snapshot evaluation) | backlog | Targeted Sprint 9, slipped. Unblocked (#497 done). |
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #501 | Hub teleport client UX (Home key, fade transition) | #491 server (Sprint 10) |
| #502 | Room reset client UX (reset plate tile, interaction verb, amber flash) | #490 (done) |
| #503 | Auto-checklist progress tracking (client-side snapshot evaluation) | #497 (done) |
| #517 | Introduce Michroma as game font | — |
| #526 | Client: mouse-relative facing and movement (D-054) | — |
| #518 | Client: wire insert open/close to PauseSimulation command | — |
| #521 | Confrontation D-033 color shift — client-side entity tint | #520 server (Sprint 10) |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot/Rust IPC), D-054 (mouse-relative facing), D-058 (auto-pause on insert open), D-066 (dual-scale grid)
- `decisions/perception.md` — D-033 (entity color = relationship to player), D-056 (insert diegetic visibility), D-057 (interaction verbs), D-060 (cognitive delay), D-063 (confrontation same box)
- `decisions/scope.md` — D-053 (movement stance toggle)
---
## Open Questions to Resolve Early
- **Q-019 (partial): Client-side entity lifecycle / StableId mapping** — #521 reads relationship state from `ObserverSnapshot` and adjusts entity tint. Confirm with server team that relationship state is included in the snapshot before #521 starts. Resolve before #521 begins.
---
## Notes
**#501 — Hub teleport client UX**
- Existing: `client/scripts/protocol/protocol.gd` handles PlayerAction serialization. `client/scripts/rendering/world_renderer.gd` handles camera.
- Deliver: Home key sends `PlayerAction::TeleportToHub`. On receipt: instant camera snap to hub spawn position + 0.3s fade-to-black-and-back tween. Effort: 0.5d. Gauntlet-mode only guard matches server behavior.
- Dependency: Wait for server #491 to merge before testing end-to-end. Can develop and unit-test client logic in isolation using local bridge stub.
**#502 — Room reset client UX**
- Existing: `client/scripts/rendering/tile_renderer.gd` handles tile visual overrides. Interaction verbs rendered by UI layer.
- Deliver: (1) `reset_plate` tile type renders with amber color override. (2) 'Reset Room' verb appears when player faces reset plate. (3) On reset confirmation: 0.15s amber flash + monologue "Systems recalibrated." fires via monologue event. Effort: 0.5d.
- Integration: Server #490 (done) already emits the reset event. Client just needs to consume it.
**#503 — Auto-checklist progress tracking**
- Existing: Checklist YAML schema defined by CI #497 (done). The test client handles its own evaluation separately.
- Deliver: Client evaluates `ObserverSnapshot` against loaded checklist YAML conditions, auto-tracks verification items with a Gauntlet-mode HUD overlay. Full tracking in test-client-only mode; lightweight overlay in Godot per R2-OQ-06. Effort: 1.5d.
- Integration: Test-client-only overlay — does not affect production game HUD. Coordinate with Hoshe on checklist condition format.
**#517 — Michroma font**
- Existing: Default Godot font throughout client UI. No font assets committed yet.
- Deliver: Download Michroma from Google Fonts (OFL license), commit to `client/assets/fonts/`. Update all Label, RichTextLabel, and LineEdit nodes to use Michroma. Acceptance: font renders legibly in HUD, dialogue box, and debug overlay at game resolution. Effort: 0.5d. Purely cosmetic — no logic changes.
- Non-obvious: Michroma is geometric sans-serif at display weights — confirm legibility at small sizes (sub-16px tooltip text) before committing globally. Consider a size floor.
**#526 — Mouse-relative facing and movement**
- Existing: `client/scripts/main.gd` handles input. WASD currently uses fixed cardinal directions. `client/scripts/protocol/protocol.gd` sends facing octant to server.
- Deliver: Mouse position determines facing direction (client-side float computed from player screen position to cursor). WASD movement becomes mouse-relative (forward = toward cursor). Server receives facing octant only, unchanged interface. Effort: 1d.
- Key decision: D-054 — mouse facing is purely client-side. Server only ever sees the octant. The full float stays in the client for rendering purposes.
**#518 — Wire insert open/close to PauseSimulation**
- Existing: Server pause system at 50%/0% tick rate exists (#406, done). `client/scripts/autoloads/sim_bridge.gd` handles server commands. Insert UI open/close events are in `client/scripts/main.gd`.
- Deliver: When insert UI opens, client sends `PauseSimulation` command. When it closes, client sends `ResumeSimulation`. D-058 confirms auto-pause in SP mode. Ticket #448 was cancelled before client wiring was completed. Effort: 0.5d.
- Non-obvious: The pause/resume must be idempotent — if the server is already paused (e.g. Gauntlet room interlude), opening the insert should not double-pause.
**#521 — Confrontation D-033 color shift**
- Existing: `client/scripts/rendering/entity_renderer.gd` applies D-033 relationship colors. `ObserverSnapshot` carries entity relationship state.
- Deliver: On confrontation delivery (server #520 decrements relationship state), entity renderer fades the entity's D-033 tint toward the new relationship color using a 0.5-1s tween. Not an instant flip — a fade that the player perceives as "something changed."
- Dependency: Blocked by server #520. Start after #520 merges.
---
## Dependency Chain
```
#502 (room reset UX) — standalone (server #490 done)
#503 (auto-checklist) — standalone (CI #497 done)
#517 (Michroma font) — standalone, parallel
#518 (insert pause wire) — standalone, parallel
#526 (mouse-relative facing) — standalone, parallel
Server #491 (hub teleport) → #501 (hub teleport UX)
Server #520 (confrontation response) → #521 (D-033 color shift)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 10 prove — client" --description "body" --base main --head client
```
+167
View File
@@ -0,0 +1,167 @@
# Sprint 10: Prove — Joint / Integration
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Agents:** All implementation agents (Dudley, Stig, Hoshe, Tyre, Justine, Inigo)
---
## Pre-Sprint Decisions
No new decisions are required before Sprint 10 begins. All sprint tickets operate within confirmed decisions. Items to monitor:
| Item | Status | Needed by |
|------|--------|-----------|
| Relationship state in ObserverSnapshot | Confirm with server team that relationship state is carried per entity in the snapshot (Q-019 partial resolution) | #521 (D-033 color shift, client) |
| CI pipeline greenlight | Team Leader must confirm before #510 starts | #510 (Gitea Actions YAML, CI) |
| Server #491 merge timing | Client #501 is blocked on it — server should prioritize #491 in the first half of sprint | #501 (hub teleport UX, client) |
| Server #520 merge timing | Client #521 is blocked on it — server should ship #520 before sprint midpoint | #521 (confrontation color shift, client) |
---
## Cross-Team Dependencies
```
Server #491 (hub teleport action)
→ Client #501 (hub teleport UX — Home key + fade)
Server #520 (confrontation world response)
→ Client #521 (D-033 color shift on confrontation)
Server #500 (content scaling test)
→ CI #499 (perf baseline — benefits from #500 data)
Server #498 (Gauntlet rooms 4-7)
→ #506 (cross-room transitions, deferred to Sprint 11)
Server #483 (replay loading)
→ Scripted Gauntlet testing (unblocks Sprint 11 automation)
Audio #327 (8 ambient + SFX assets)
→ Client AudioManager (automatic registration via directory scan — no client code change)
→ D-073 zone crossfade stub can be tested with real assets for the first time
```
**Critical path:** `Server #491 → Client #501` and `Server #520 → Client #521`. Both server tickets need to merge before sprint midpoint to give client time to complete their dependent work.
---
## Sprint Completion Proof
Sprint 10 is complete when all of the following are observable:
**Hub teleport**
- Pressing Home key in Gauntlet mode sends `TeleportToHub`, player entity moves to hub spawn tile
- 0.3s fade-to-black-and-back plays on teleport
- Dialogue/monologue/interaction buffer is cleared on teleport
- Non-Gauntlet maps: action is rejected server-side with log warning; client shows no effect
**Room reset UX**
- `reset_plate` tile type renders with amber color
- 'Reset Room' verb appears in interaction menu when player faces reset plate
- On reset: 0.15s amber flash + "Systems recalibrated." monologue line plays
**Auto-checklist tracking**
- In test-client mode: checklist YAML loads, client evaluates ObserverSnapshot against conditions, verified items tick off automatically in HUD overlay
- Checklist HUD is absent in non-test-client Godot mode
**Gauntlet expansion**
- Server boots with Gauntlet content and all 7 rooms (existing 3 + new 4) load without panic
- Room constants for all 4 new rooms in `server/src/test_world/constants.rs`
- Room reset works correctly in new rooms (entity positions and fog reset to tick-0 state)
**Content scaling**
- `make test-server` includes the content scaling test: baseline run passes, extra-NPC comparative run stays within D-026 tick budget
**Performance baseline**
- `make perf-baseline` runs without error and produces `tests/perf/baseline.json` (or equivalent)
- Baseline file committed to repo
**Replay loading**
- `tooling/test-client --replay <file.jsonl>` loads the file and sends PlayerInput at correct tick timing against a live server
**Client UX polish**
- Michroma font renders in all Labels/RichTextLabels in HUD and dialogue box
- WASD movement is mouse-relative (forward = toward cursor direction)
- Insert open sends `PauseSimulation`, insert close sends `ResumeSimulation`
**Confrontation system**
- Confrontation delivery: target NPC shifts to Tier 2 animation, relationship state decrements, monologue spike fires (server)
- Client: entity D-033 tint fades to new relationship color over 0.5-1s on confrontation delivery
**Audio**
- 8 OGG files present in `audio` branch under `client/audio/`
- `AudioManager` registers all 8 at startup (log output confirms count)
- Ambient loops play without audible seam
- Monologue chimes have no reverb (insert-tech aesthetic per D-074)
**CI (if greenlit)**
- Gitea Actions YAML committed to `.gitea/workflows/`
- Commit tier runs in <2min on push
- PR merge gate wraps `make pre-pr` in <15min
---
## Test Plan Alignment (D-030)
| D-030 Phase | Coverage | Sprint 10 Status |
|-------------|----------|-----------------|
| Phase 1 (infra) | Done Sprints 1-2 | — |
| Phase 2 (integration) | Done Sprints 3-5 | — |
| Phase 3 (CauseChain, divergent snapshots) | Gauntlet golden suite (#485, Sprint 9) | Expanding with rooms 4-7 |
| Replay / scripted testing | #483 (test client replay loading) | In scope |
| Performance regression baseline | #499 (make perf-baseline) | In scope |
| Content scaling | #500 (baseline + NPC pack comparative) | In scope |
| Pre-PR gate | `make pre-pr` (Sprint 9, done) | Stable |
---
## Open Questions to Resolve Early
- **Q-019 (partial): Client-side entity StableId / relationship state in snapshot** — Client #521 needs relationship state per entity in ObserverSnapshot. Server team should confirm scope before #521 starts. No formal Q-NNN resolution required — just a server team confirmation.
- **CI pipeline greenlight (no Q-NNN)** — #510 has an explicit "deferred until lead greenlights" note. Team Leader to confirm before CI team starts that ticket.
---
## Sequence Recommendation
**Days 1-3 (all teams in parallel):**
- Server: start #491 immediately (no blockers). Priority — unblocks client #501.
- Server: start #483 (no blockers). `replay.rs` stub exists in `tooling/test-client/src/`.
- Server: start #498 (no blockers). 4 new Gauntlet rooms; follow existing room pattern.
- Server: start #514 (small, standalone).
- Server: start #519 (standalone, #427 done).
- Client: start #502 (no blockers, #490 done).
- Client: start #503 (no blockers, #497 done).
- Client: start #517 (standalone).
- Client: start #526 (standalone).
- Client: start #518 (standalone).
- CI: start #499 (no blockers, #487 done).
- CI: start #515 (standalone, #464 done).
- Audio: start #327.
**Days 4-6:**
- Server: start #520 (no blockers). Priority — unblocks client #521.
- Server: start #500 (no blockers, #489 done).
- Client: #501 unblocks once server #491 merges.
**Days 7-10:**
- Client: #521 unblocks once server #520 merges.
- CI: #510 starts only after lead greenlight (may be later in sprint or deferred).
- All teams: verification against Sprint Completion Proof criteria.
---
## PR Workflow
Each team submits their own PR from their branch. Integration is via merge to `main`. No joint branch — all tickets are single-team. The `joint.md` tracks integration readiness but does not produce a separate branch.
```bash
# Verify sprint assignment before PR
db/connectors/sprint status --team <team>
# Standard PR (all flags required — see CLAUDE.md)
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(<scope>): sprint 10 prove — <team summary>" \
--description "body" --base main --head <branch>
```
+103
View File
@@ -0,0 +1,103 @@
# Sprint 10: Prove — Server Tasks
**Goal:** Expand the Gauntlet test suite with new rooms and cross-room scenarios, wire the audio architecture end-to-end, and complete client UX polish from Sprint 9 carry-overs — proving the full system holds together.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (arch review), Hoshe (QA)
---
## Carry-over from Sprint 9
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #491 | Hub teleport action (PlayerAction::TeleportToHub) | backlog | Targeted for Sprint 9, slipped due to #487 dependency completing late. Unblocked as of sprint close. |
| #483 | Test client replay loading + tick-scheduled sending | backlog | Critical for scripted Gauntlet testing. Targeted Sprint 9, slipped. Unblocked (#480 done). |
| #500 | Content scaling test (baseline + extra NPC comparative) | backlog | Targeted Sprint 9, deferred. Unblocked (#489 done). |
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #491 | Hub teleport action (PlayerAction::TeleportToHub) | #487 (done) |
| #483 | Test client replay loading + tick-scheduled sending | #480 (done) |
| #498 | Gauntlet rooms 4-7 (Interaction Gallery, Fog Theater, Crowd Plaza, Dialogue Room) | #487 (done) |
| #500 | Content scaling test (baseline + extra NPC comparative) | #489 (done) |
| #514 | blocked_entities debug field on ObserverSnapshot | — |
| #519 | Walk-away NPC reaction — server-side Phase 2 (animation shift, routine change) | #427 (done) |
| #520 | Confrontation world response — server-side trigger | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-010 (deterministic simulation), D-026 (100ms tick budget), D-041 (knowledge graph data model)
- `decisions/perception.md` — D-059 (fog/shader five layers), D-060 (cognitive delay), D-063 (confrontation world response), D-064 (walk-away three phases)
- `decisions/scope.md` — D-038 (audio in v0.1 scope)
---
## Notes
**#491 — Hub teleport action**
- Existing: `server/src/test_world/rooms/hub.rs` for the hub spawn location. `PlayerAction` enum lives in `server/src/bridge/`.
- Deliver: `PlayerAction::TeleportToHub` variant. On receipt: move player entity to hub spawn tile, clear dialogue/monologue/interaction buffer. Does NOT affect room state, inventory, game time, or knowledge graph. Reject the action with a log warning on non-Gauntlet maps.
- Integration: Client #501 depends on this — ship it first half of sprint.
**#483 — Test client replay loading**
- Existing: `tooling/test-client/src/` has `main.rs`, `golden.rs`, `replay.rs` (stub). The `replay.rs` module exists but the loading logic was deferred.
- Deliver: Load a JSONL replay file (one JSON array per tick), send `PlayerInput` at correct tick timing. Target: `tooling/test-client/src/replay.rs`.
- Integration: Enables scripted Gauntlet testing end-to-end — unblocks the cross-room test scenarios in later sprints.
**#498 — Gauntlet rooms 4-7**
- Existing: `server/src/test_world/rooms/` has `hub.rs`, `inventory_warehouse.rs`, `occlusion_corridor.rs`, `pause_chamber.rs`. The `mod.rs` wires them together.
- Deliver: Four new rooms — Interaction Gallery (24x20, 5 entities, D-057), Fog Theater (44x32, 4 entities, D-059), Crowd Plaza (32x32, 15 entities), Dialogue Room (28x20, 4 entities, D-041). Follow the pattern of existing room modules. Each room must have constants in `server/src/test_world/constants.rs`.
- Integration: Unblocks #506 (cross-room transition tests) in a future sprint.
**#500 — Content scaling test**
- Existing: The baseline test infrastructure is in place post-#489. Content packs are in `server/src/content/`.
- Deliver: A test that runs the baseline NPC pack then the same scenario with additional NPCs, asserts tick timing stays within D-026 budget and behavior is unchanged for original NPCs.
- Integration: Feeds into CI perf baseline (#499) which CI team handles.
**#514 — blocked_entities debug field**
- Existing: `ObserverSnapshot` in `server/src/bridge/`. LOS computation is in `server/src/perception/`.
- Deliver: Add `blocked_entities: Vec<StableId>` (or `Option<Vec<StableId>>`) to `ObserverSnapshot`. Feasibility confirmed at ~300 tile lookups/tick (R2-OQ-05). Debug-only field; `#[serde(default)]` so client gracefully ignores on older snapshots.
- Integration: Improves test client debug output. Standalone, no cross-team dependency.
**#519 — Walk-away NPC reaction Phase 2**
- Existing: Phase 1 (client fade, #437) and Phase 3 (KG recording, #427) are done. Phase 2 is the missing server-side piece.
- Deliver: On `PlayerAction::WalkAway` (or dialogue exit event), server triggers: (1) target NPC shifts animation tier to Tier 2 (D-047), (2) NPC routine deviation is recorded in their state machine. D-064 Phase 2 scope.
- Integration: Standalone server-side work. No client changes required.
**#520 — Confrontation world response**
- Existing: Confrontation delivery is wired through dialogue system. D-063 specifies the world response.
- Deliver: On confrontation delivery: (1) target NPC shifts to Tier 2 animation (D-047 animated tier), (2) NPC relationship state decremented (affects D-033 color which client #521 renders), (3) monologue spike event emitted. Three separate effects, all server-authoritative.
- Integration: Client #521 blocked by this — complete #520 first half of sprint.
---
## Dependency Chain
```
#483 (replay loading) — standalone, parallel
#491 (hub teleport server) → #501 (hub teleport client UX, cross-team)
#498 (Gauntlet rooms 4-7) → #506 (cross-room transitions, future sprint)
#500 (content scaling) → #499 (perf baseline, CI team)
#514 (blocked_entities) — standalone, parallel
#519 (walk-away phase 2) — standalone, parallel
#520 (confrontation world response) → #521 (D-033 color shift, client team)
```
---
## PR Workflow
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 10 prove — server" --description "body" --base main --head server
```
+37
View File
@@ -0,0 +1,37 @@
name: The Settled Reach
version: 0.1.10
repository: settled-reach
codename: commonwealth
description: >
Top-down immersive sim with occlusion-based detection mechanics and
combat elements. Single-character perspective where asymmetric information
is the core gameplay mechanic. A Rimworld-style storyteller drives
emergent narrative across a detective-smuggler dual-lens campaign.
setting: >
Original science fiction universe — the Settled Reach, a network of
star systems connected by Founder Gates. Neural lattice technology
enables soft immortality, forking, and re-embodiment. The v0.1
vertical slice takes place in Sova Transit District, Krenn System.
architecture:
client: Godot 4 (GDScript)
server: Rust with bevy_ecs (simulation server)
transport: subprocess/IPC via TCP with MessagePack serialization
protocol: length-prefixed MessagePack frames (4-byte big-endian)
determinism: ChaCha20 seeded RNG, deterministic tick processing
simulation:
tick_rate: 10 ticks per game-minute
time_model: 4 day phases (Morning, Afternoon, Evening, Night)
grid: dual-scale (0.5m simulation tiles, 1m visual tiles)
visibility: symmetric shadowcasting + forward/peripheral/blind vision cone
knowledge: per-entity knowledge graphs with confidence decay
content:
format: YAML with JSON Schema validation
structure: campaigns/systems/stations/districts hierarchy
npcs: 10-axis model (7 essential + 3 supporting) with CombatCapability
dialogue: tagged line pools with 4-layer relational filtering
population: 30% flat / 50% mundane triangles / 20% intrigue-entangled
+1 -1
View File
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.0"
version = "0.1.9"
dependencies = [
"bevy_app",
"bevy_ecs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.0"
version = "0.1.10"
edition = "2021"
[dependencies]
+3
View File
@@ -178,11 +178,14 @@ impl Plugin for BridgePlugin {
crate::simulation::dialogue::process_walk_away
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::dialogue::process_talk_interaction),
crate::simulation::dialogue::process_confrontation_response
.after(crate::simulation::input::process_player_input),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_confrontation_response)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+24
View File
@@ -159,6 +159,12 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
.ok();
}
// Blocked entities (debug, #514)
if !snapshot.blocked_entities.is_empty() {
let ids: Vec<String> = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect();
writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok();
}
writeln!(out, "===").ok();
out
}
@@ -281,6 +287,7 @@ mod tests {
}),
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
}
}
@@ -402,10 +409,27 @@ mod tests {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
assert!(text.contains("Player (-1,-1)"));
assert!(text.contains("Tiles: 0 visible"));
}
#[test]
fn blocked_entities_rendered() {
let mut snap = make_snapshot();
snap.blocked_entities = vec![42, 99, 1024];
let text = format_snapshot_text(&snap);
assert!(text.contains("Blocked (LOS): 3"));
assert!(text.contains("[42, 99, 1024]"));
}
#[test]
fn blocked_entities_empty_not_rendered() {
let snap = make_snapshot();
let text = format_snapshot_text(&snap);
assert!(!text.contains("Blocked (LOS)"));
}
}
+12 -2
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 8;
pub const PROTOCOL_VERSION: u8 = 9;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -27,10 +27,11 @@ pub const PROTOCOL_VERSION: u8 = 8;
/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065).
/// v7 adds: pending_recognitions (#423, D-060 cognitive delay).
/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline).
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 6.
/// Protocol version for forward compatibility. Current: 9.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -68,6 +69,11 @@ pub struct ObserverSnapshot {
/// Client shows speaker name + dialogue text in a dialogue box.
#[serde(default)]
pub dialogue_response: Option<DialogueResponseEvent>,
/// Debug field: entity IDs on the same z-level that are not visible due to
/// LOS obstruction or being outside the vision cone (#514).
/// Sorted ascending for deterministic output. Client can safely ignore.
#[serde(default)]
pub blocked_entities: Vec<u64>,
}
/// Game time data for client display (D-031)
@@ -316,6 +322,10 @@ pub enum PlayerAction {
ToggleStanceUp,
/// Move one step down the stance ladder (toward Crouch) per D-053
ToggleStanceDown,
/// Teleport player to the Gauntlet hub spawn point (#491).
/// Clears dialogue, monologue, and interaction buffers.
/// Rejected with a log warning on non-Gauntlet maps.
TeleportToHub,
}
impl PlayerAction {
+17
View File
@@ -129,6 +129,23 @@ pub enum RelationshipState {
Hostile,
}
impl RelationshipState {
/// Decrement relationship state toward more negative (D-063 confrontation response).
///
/// Friendly → Known → PersonOfInterest → Hostile.
/// Unknown stays Unknown (can't confront a stranger meaningfully).
/// Hostile stays Hostile — floor, does not wrap or panic.
pub fn decrement(self) -> Self {
match self {
Self::Friendly => Self::Known,
Self::Known => Self::PersonOfInterest,
Self::PersonOfInterest => Self::Hostile,
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
Self::Hostile => Self::Hostile, // floor: already worst state
}
}
}
// --- Entity Knowledge ---
/// What entity A knows about entity B.
+36
View File
@@ -35,6 +35,42 @@ impl Plugin for NpcPlugin {
#[derive(Component, Debug)]
pub struct Npc;
/// NPC animation tier (D-047).
///
/// Tier 1 (clear): public daily activities — instantly readable.
/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action
/// but cannot determine the intention.
///
/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum AnimationTier {
/// Clear, readable public activities (walking, working, talking).
#[default]
Tier1,
/// Ambiguous, privately motivated behaviors (pausing, lingering, looking around).
Tier2,
}
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
///
/// Inserted when a player action causes an NPC to break from their scheduled
/// behavior. Acts as a hook for the storyteller system and affects future
/// interactions (e.g., second-approach dialogue differences).
#[derive(Component, Debug, Clone)]
pub struct RoutineDeviation {
pub trigger: DeviationTrigger,
pub tick: u64,
}
/// What caused an NPC's routine deviation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviationTrigger {
/// Player walked away mid-dialogue (D-064 Phase 2).
WalkAway,
/// Player delivered a confrontation (D-063).
Confrontation,
}
// ---------------------------------------------------------------------------
// Axis 1: Want (D-024)
// ---------------------------------------------------------------------------
+2 -4
View File
@@ -142,10 +142,8 @@ pub fn emit_observation_events(
let pending_ids: Vec<crate::knowledge::types::StableId> =
delay.pending().iter().map(|p| p.stable_id).collect();
for sid in pending_ids {
if !visible_stable_ids.contains(&sid.0) {
if delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
}
}
+24 -14
View File
@@ -116,7 +116,7 @@ pub fn compute_observer_snapshot(
})
.unwrap_or_default();
let (mut entities, visible_ids) =
let (mut entities, visible_ids, blocked_entities) =
filter_visible_entities(&geometry, &registry, observer_kg, &all_entities);
collect_remembered_entities(
@@ -206,11 +206,13 @@ pub fn compute_observer_snapshot(
current_monologue,
pending_recognitions,
dialogue_response,
blocked_entities,
});
}
/// Filter entities by visibility using precomputed geometry.
/// Returns (visible entities, set of visible wire IDs).
/// Returns (visible entities, set of visible wire IDs, blocked entity IDs).
/// Blocked entities are on the same z-level but not in visible_positions (#514).
#[allow(clippy::type_complexity)]
fn filter_visible_entities(
geometry: &VisibilityGeometry,
@@ -222,15 +224,31 @@ fn filter_visible_entities(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
for (entity, pos, is_player, is_npc) in all_entities.iter() {
if pos.z != geometry.observer_z {
continue;
}
// Resolve wire ID early — needed for both visible and blocked paths
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity not in EntityRegistry");
entity.to_bits()
});
if !geometry.visible_positions.contains(&(pos.x, pos.y)) {
// Same z-level but not visible — blocked by LOS or outside vision cone.
// Exclude the player entity (always at origin, always visible).
if is_player.is_none() {
blocked_ids.insert(wire_id);
}
continue;
}
@@ -257,16 +275,6 @@ fn filter_visible_entities(
RelationshipState::Unknown
};
// Fallback to Entity::to_bits() is intentional for per-frame systems:
// panicking would crash the server every tick. The error log makes this
// loud enough to catch in testing while keeping the server alive.
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity visible but not in EntityRegistry");
entity.to_bits()
});
visible_ids.insert(wire_id);
entities.push(VisibleEntity {
entity_id: wire_id,
@@ -280,7 +288,9 @@ fn filter_visible_entities(
});
}
(entities, visible_ids)
// BTreeSet iteration is sorted — deterministic output guaranteed
let blocked_vec: Vec<u64> = blocked_ids.into_iter().collect();
(entities, visible_ids, blocked_vec)
}
/// Collect remembered entities from the knowledge graph — entities the observer
+210
View File
@@ -2030,3 +2030,213 @@ fn no_cognitive_delay_component_means_empty_pending_recognitions() {
"no CognitiveDelay component should produce empty pending_recognitions"
);
}
// -----------------------------------------------------------------------
// blocked_entities debug field tests (#514)
// -----------------------------------------------------------------------
#[test]
fn blocked_entities_empty_when_all_visible() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.is_empty(),
"no blocked entities when NPC is in LOS"
);
}
#[test]
fn npc_behind_wall_appears_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Wall between player and NPC
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
// NPC behind the wall
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.contains(&npc_sid.0),
"NPC behind wall should appear in blocked_entities"
);
// Not in visible entities
let npc_visible = snapshot
.entities
.iter()
.any(|e| e.entity_id == npc_sid.0);
assert!(!npc_visible, "NPC should not be in visible entities");
}
#[test]
fn npc_behind_player_appears_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC far behind player (south, outside vision cone)
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.blocked_entities.contains(&npc_sid.0),
"NPC in blind spot should appear in blocked_entities"
);
}
#[test]
fn different_z_level_not_in_blocked_entities() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC on a different z-level
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)))
.id();
let npc_sid = registry.register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
!snapshot.blocked_entities.contains(&npc_sid.0),
"NPC on different z-level should NOT be in blocked_entities"
);
}
#[test]
fn blocked_entities_sorted_ascending() {
// Multiple blocked NPCs should appear in ascending entity_id order
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// Wall blocks north
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
// Two NPCs behind wall + one behind player
let npc_a = world
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
.id();
let npc_a_sid = registry.register(npc_a);
let npc_b = world
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
.id();
let npc_b_sid = registry.register(npc_b);
let npc_c = world
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
.id();
let npc_c_sid = registry.register(npc_c);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
))
.id();
registry.register(player);
world.insert_resource(registry);
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(snapshot.blocked_entities.len() >= 3);
// Must be sorted ascending (BTreeSet guarantee)
for i in 1..snapshot.blocked_entities.len() {
assert!(
snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i],
"blocked_entities not sorted: {:?}",
snapshot.blocked_entities
);
}
// All three NPCs should be present
assert!(snapshot.blocked_entities.contains(&npc_a_sid.0));
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
}
+427 -9
View File
@@ -18,12 +18,13 @@
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
@@ -117,6 +118,18 @@ pub struct ActiveDialogue {
#[derive(Component, Debug)]
pub struct WalkAwayRequest;
/// Marker: player delivered a confrontation this tick (#520, D-063).
///
/// Set by process_player_input when Interact{verb: "Confront"} is received.
/// Consumed by process_confrontation_response each tick. Triggers:
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
/// 2. Observer KG relationship state decremented (D-033 color fade)
/// 3. Monologue spike event emitted
#[derive(Component, Debug)]
pub struct ConfrontationDelivered {
pub target: Entity,
}
/// Buffer holding the dialogue response for snapshot inclusion.
///
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
@@ -299,7 +312,7 @@ pub fn select_dialogue_line<'a>(
/// line to DialogueResponseBuffer.
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_talk_interaction(
mut commands: Commands,
time: Res<SimulationTime>,
@@ -478,13 +491,15 @@ pub fn process_talk_interaction(
// System: process_walk_away (D-064)
// ---------------------------------------------------------------------------
/// Process walk-away requests during active dialogue.
/// Process walk-away requests during active dialogue (D-064 Phases 2+3).
///
/// When the player moves (WASD) during an active dialogue, the client sends
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
/// 1. Emits IncompleteInteraction knowledge event (recorded in KG)
/// 2. Clears ActiveDialogue state
/// 3. Removes the WalkAwayRequest marker
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation)
/// 2. NPC routine deviation recorded (storyteller hook)
/// 3. Emits IncompleteInteraction knowledge event (recorded in KG)
/// 4. Clears ActiveDialogue state
/// 5. Removes the WalkAwayRequest marker
///
/// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op).
///
@@ -500,21 +515,38 @@ pub fn process_walk_away(
};
if let Some(active_dialogue) = active_dialogue_opt {
// Emit IncompleteInteraction knowledge event
let target = active_dialogue.target;
// Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047)
commands
.entity(target)
.insert(crate::npc::AnimationTier::Tier2);
// Phase 2, Effect 2: Record routine deviation on target NPC
commands
.entity(target)
.insert(crate::npc::RoutineDeviation {
trigger: crate::npc::DeviationTrigger::WalkAway,
tick: time.tick,
});
// Phase 3: Emit IncompleteInteraction knowledge event
event_queue.push(crate::knowledge::KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
target: active_dialogue.target,
target,
interaction_type: active_dialogue.interaction_type,
},
});
tracing::debug!(
"Walk-away during {:?} dialogue at tick {} (started tick {})",
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
target {:?} Tier2 animation + routine deviation",
active_dialogue.interaction_type,
time.tick,
active_dialogue.started_tick,
target,
);
commands.entity(player_entity).remove::<ActiveDialogue>();
@@ -525,6 +557,109 @@ pub fn process_walk_away(
commands.entity(player_entity).remove::<WalkAwayRequest>();
}
/// Hardcoded confrontation monologue lines (D-063).
/// Fired as a monologue spike when the player delivers a confrontation.
/// Future: move to content pools with trigger="confrontation_delivered".
const CONFRONTATION_LINES: &[(&str, &str)] = &[
(
"confront_01",
"That changed everything between us. No going back.",
),
(
"confront_02",
"The look on their face... they know I know.",
),
(
"confront_03",
"Cards on the table. Let's see what happens next.",
),
];
/// Process confrontation world response (#520, D-063).
///
/// Reads ConfrontationDelivered marker (set by input system), applies three
/// server-authoritative effects:
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
/// 2. Observer's KG relationship state decremented (D-033 color fade)
/// 3. Monologue spike: immediate monologue line bypassing cooldown
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
pub fn process_confrontation_response(
mut commands: Commands,
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
mut query: Query<
(
Entity,
&ConfrontationDelivered,
&mut KnowledgeGraph,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
) {
let Ok((
player_entity,
confrontation,
mut observer_kg,
mut monologue_buf,
mut monologue_state,
)) = query.single_mut()
else {
return;
};
let target = confrontation.target;
// Effect 1: Shift target NPC to Tier 2 animation (D-047)
// + record routine deviation (symmetric with walk-away path)
commands.entity(target).insert((
crate::npc::AnimationTier::Tier2,
crate::npc::RoutineDeviation {
trigger: crate::npc::DeviationTrigger::Confrontation,
tick: time.tick,
},
));
// Effect 2: Decrement observer's relationship with the target (D-033 color fade)
if let Some(target_sid) = registry.to_stable(target) {
let old_rel = observer_kg.relationship_with(&target_sid);
let new_rel = old_rel.decrement();
if new_rel != old_rel {
observer_kg.set_relationship(&target_sid, new_rel);
tracing::info!(
target_id = target_sid.0,
?old_rel,
?new_rel,
"Confrontation: relationship decremented"
);
}
}
// Effect 3: Monologue spike — bypass cooldown, fire immediately
let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len());
let (id, text) = CONFRONTATION_LINES[idx];
monologue_buf.set(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: 5.0,
});
monologue_state.last_fired_tick = time.tick;
tracing::info!(
tick = time.tick,
monologue_id = id,
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
);
// Clean up marker
commands
.entity(player_entity)
.remove::<ConfrontationDelivered>();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1362,5 +1497,288 @@ mod tests {
);
}
// -- Walk-away Phase 2 tests (D-064, #519) ---------------------------------
#[test]
fn walk_away_shifts_npc_to_tier2_animation() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::AnimationTier;
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 10,
},
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
let tier = world.get::<AnimationTier>(npc).unwrap();
assert_eq!(
*tier,
AnimationTier::Tier2,
"Walk-away should shift NPC to Tier 2 animation"
);
}
#[test]
fn walk_away_records_routine_deviation() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::{DeviationTrigger, RoutineDeviation};
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 10,
},
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
let deviation = world.get::<RoutineDeviation>(npc).unwrap();
assert_eq!(
deviation.trigger,
DeviationTrigger::WalkAway,
"Deviation trigger should be WalkAway"
);
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
}
#[test]
fn walk_away_without_dialogue_does_not_affect_npcs() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::{AnimationTier, RoutineDeviation};
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
// Player with WalkAwayRequest but NO ActiveDialogue
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnimationTier>(npc).is_none(),
"NPC should not get AnimationTier when no dialogue was active"
);
assert!(
world.get::<RoutineDeviation>(npc).is_none(),
"NPC should not get RoutineDeviation when no dialogue was active"
);
}
use rand::SeedableRng;
// === Confrontation Response Tests (#520, D-063) ===
#[test]
fn confrontation_shifts_npc_to_tier2() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
kg.set_relationship(&npc_sid, RelationshipState::Known);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
world.flush();
let tier = world.get::<crate::npc::AnimationTier>(npc);
assert_eq!(
tier,
Some(&crate::npc::AnimationTier::Tier2),
"NPC should shift to Tier2 after confrontation"
);
// RoutineDeviation should be recorded (symmetric with walk-away)
let deviation = world.get::<crate::npc::RoutineDeviation>(npc);
assert!(deviation.is_some(), "NPC should get RoutineDeviation after confrontation");
assert_eq!(
deviation.unwrap().trigger,
crate::npc::DeviationTrigger::Confrontation,
"Deviation trigger should be Confrontation"
);
}
#[test]
fn confrontation_decrements_relationship() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
kg.set_relationship(&npc_sid, RelationshipState::Known);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
assert_eq!(
player_kg.relationship_with(&npc_sid),
RelationshipState::PersonOfInterest,
"Known → PersonOfInterest after confrontation"
);
}
#[test]
fn confrontation_emits_monologue_spike() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
let mut buffer = world.get_mut::<MonologueBuffer>(player).unwrap();
let event = buffer.take();
assert!(event.is_some(), "Monologue spike should be emitted");
assert!(
event.unwrap().id.starts_with("confront_"),
"Should be a confrontation monologue line"
);
}
#[test]
fn confrontation_clears_marker() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ConfrontationDelivered>(player).is_none(),
"Marker should be removed after processing"
);
}
}
+296 -3
View File
@@ -75,7 +75,7 @@ impl InputQueue {
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
mut time: ResMut<SimulationTime>,
@@ -104,8 +104,14 @@ pub fn process_player_input(
for input in inputs {
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
// Only Pause/Unpause are processed — everything else is discarded.
if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) {
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
if paused
&& !matches!(
input.action,
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
)
{
continue;
}
match input.action {
@@ -196,6 +202,15 @@ pub fn process_player_input(
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Reset") => {
handle_reset(
&mut commands,
@@ -222,6 +237,9 @@ pub fn process_player_input(
tracing::debug!("WalkAway: marker set on player");
}
}
PlayerAction::TeleportToHub => {
handle_teleport_to_hub(&mut player_query, &mut commands);
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
@@ -400,6 +418,65 @@ fn handle_talk(
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
#[allow(clippy::type_complexity)]
fn handle_confront(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Confront verb without target_entity_id");
return;
};
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Confront: target entity not in registry");
return;
};
// Server-side range check: reject Confront if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Confront: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::ConfrontationDelivered {
target: target_entity,
});
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
@@ -512,6 +589,63 @@ fn handle_reset(
);
}
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
///
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
/// dialogue, monologue, and interaction markers to prevent stale state.
///
/// Does NOT affect: room state, inventory, game time, knowledge graph.
#[allow(clippy::type_complexity)]
fn handle_teleport_to_hub(
player_query: &mut Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
commands: &mut Commands,
) {
#[cfg(not(feature = "gauntlet"))]
{
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
return;
}
#[cfg(feature = "gauntlet")]
{
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let hub_spawn = crate::test_world::constants::HUB.spawn;
// Move player to hub spawn
commands.entity(player_entity).insert(hub_spawn);
// Clear any pending movement
commands.entity(player_entity).remove::<MoveIntent>();
// Clear dialogue/interaction markers (including mid-confrontation state)
commands
.entity(player_entity)
.remove::<crate::simulation::dialogue::TalkRequest>()
.remove::<crate::simulation::dialogue::ActiveDialogue>()
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
.remove::<crate::simulation::dialogue::ConfrontationDelivered>();
tracing::info!(
x = hub_spawn.x,
y = hub_spawn.y,
z = hub_spawn.z,
"TeleportToHub: player moved to hub spawn"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1690,4 +1824,163 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
// === TeleportToHub Tests (#491) ===
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_moves_player() {
// #491: TeleportToHub moves player to hub spawn 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>();
// Spawn player at a non-hub position
let player = world
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
let hub_spawn = crate::test_world::constants::HUB.spawn;
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_clears_dialogue_markers() {
// #491: TeleportToHub removes ActiveDialogue, TalkRequest,
// WalkAwayRequest, and ConfrontationDelivered.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Spawn a fake NPC target
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
// Spawn player with active dialogue state + mid-confrontation marker
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(84, 58, 0),
crate::simulation::dialogue::TalkRequest { target: npc },
crate::simulation::dialogue::ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 0,
},
crate::simulation::dialogue::WalkAwayRequest,
crate::simulation::dialogue::ConfrontationDelivered { target: npc },
))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert!(
world
.get::<crate::simulation::dialogue::TalkRequest>(player)
.is_none(),
"TalkRequest cleared after teleport"
);
assert!(
world
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
.is_none(),
"ActiveDialogue cleared after teleport"
);
assert!(
world
.get::<crate::simulation::dialogue::WalkAwayRequest>(player)
.is_none(),
"WalkAwayRequest cleared after teleport"
);
assert!(
world
.get::<crate::simulation::dialogue::ConfrontationDelivered>(player)
.is_none(),
"ConfrontationDelivered cleared after teleport"
);
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_clears_move_intent() {
// #491: TeleportToHub removes any pending MoveIntent.
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(84, 58, 0),
MoveIntent {
target: TilePosition::new(85, 58, 0),
},
))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert!(
world.get::<MoveIntent>(player).is_none(),
"MoveIntent cleared after teleport"
);
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_allowed_while_paused() {
// #491: TeleportToHub is a QA action — allowed even when paused.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
let hub_spawn = crate::test_world::constants::HUB.spawn;
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
}
}
+6
View File
@@ -108,6 +108,12 @@ impl MonologueBuffer {
pub fn take(&mut self) -> Option<MonologueEvent> {
self.event.take()
}
/// Set a monologue event, replacing any pending event.
/// Used by confrontation response (#520, D-063) to emit a monologue spike.
pub fn set(&mut self, event: MonologueEvent) {
self.event = Some(event);
}
}
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
+34 -3
View File
@@ -189,16 +189,19 @@ 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);
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55);
/// 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.
/// Derived from StableId ranges of all rooms + player + reset plates.
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
+ (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_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
+ (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
+ (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize
+ (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
@@ -233,6 +236,34 @@ mod tests {
assert_eq!(room.name, "pause_chamber");
}
#[test]
fn room_at_finds_fog_theater() {
let pos = TilePosition { x: 56, y: 18, z: 0 };
let room = room_at(&pos).expect("Fog Theater observer should be in a room");
assert_eq!(room.name, "fog_theater");
}
#[test]
fn room_at_finds_interaction_gallery() {
let pos = TilePosition { x: 14, y: 92, z: 0 };
let room = room_at(&pos).expect("Interaction Gallery observer should be in a room");
assert_eq!(room.name, "interaction_gallery");
}
#[test]
fn room_at_finds_dialogue_room() {
let pos = TilePosition { x: 50, y: 114, z: 0 };
let room = room_at(&pos).expect("Dialogue Room observer should be in a room");
assert_eq!(room.name, "dialogue_room");
}
#[test]
fn room_at_finds_crowd_plaza() {
let pos = TilePosition { x: 96, y: 94, z: 0 };
let room = room_at(&pos).expect("Crowd Plaza observer should be in a room");
assert_eq!(room.name, "crowd_plaza");
}
#[test]
fn room_at_returns_none_for_corridor() {
// Point inside corridor-E (between Hub and Occlusion)
+78 -18
View File
@@ -19,14 +19,14 @@
//! StableId ranges (from gestalt-round3.md):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8 (reserved, not yet built)
//! Fog Theater: 5-8
//! Occlusion Corridor: 9-12
//! Inventory Warehouse: 13-23
//! Interaction Gallery: 24-28 (reserved, not yet built)
//! Interaction Gallery: 24-28
//! 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)
//! Dialogue Room: 30-33
//! Crowd Plaza: 34-48
//! Reset plates: 49-55
#[cfg(feature = "gauntlet")]
pub mod constants;
@@ -86,14 +86,22 @@ pub fn setup_gauntlet(app: &mut App) {
// 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, 28, 2, 44, 32); // Fog Theater
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, 2, 82, 24, 20); // Interaction Gallery
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
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
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
// 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
@@ -142,8 +150,8 @@ pub fn setup_gauntlet(app: &mut App) {
// --- 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);
// --- Fog Theater (StableId 5-8) ---
rooms::fog_theater::spawn_entities(app, &mut registry);
// --- Occlusion Corridor (StableId 9-12) ---
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
@@ -151,23 +159,29 @@ pub fn setup_gauntlet(app: &mut App) {
// --- 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);
// --- Interaction Gallery (StableId 24-28) ---
rooms::interaction_gallery::spawn_entities(app, &mut registry);
// --- 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);
// --- Dialogue Room (StableId 30-33) ---
rooms::dialogue_room::spawn_entities(app, &mut registry);
// --- Reset plates (StableId 49-51) ---
// --- Crowd Plaza (StableId 34-48) ---
rooms::crowd_plaza::spawn_entities(app, &mut registry);
// --- Reset plates (StableId 49-55) ---
// 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")),
("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")),
("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")),
("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")),
("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")),
];
for &(room_name, pos) in reset_plates {
let entity = app
@@ -189,6 +203,15 @@ pub fn setup_gauntlet(app: &mut App) {
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
// Fog Theater entities (StableId 5-8): NPCs only
for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("fog_theater", entity, *pos, false);
}
}
}
// 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)) {
@@ -208,6 +231,15 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("interaction_gallery", entity, *pos, false);
}
}
}
// 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) {
@@ -215,6 +247,24 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Dialogue Room entities (StableId 30-33): NPCs only
for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("dialogue_room", entity, *pos, false);
}
}
}
// Crowd Plaza entities (StableId 34-48): NPCs only
for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("crowd_plaza", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
@@ -331,9 +381,9 @@ mod tests {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
}
// Fog Theater 5-8 reserved (no entities)
// Fog Theater at 5-8
for id in 5..=8 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
}
// Occlusion Corridor at 9-12
@@ -346,15 +396,25 @@ mod tests {
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
}
// Interaction Gallery 24-28 reserved
// Interaction Gallery at 24-28
for id in 24..=28 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id);
}
// Pause Chamber at 29
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
// Reset plates at 49-51
// Dialogue Room at 30-33
for id in 30..=33 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
}
// Crowd Plaza at 34-48
for id in 34..=48 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id);
}
// Reset plates at 49-55
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
@@ -0,0 +1,85 @@
//! Crowd Plaza — Room 7 (32x32)
//!
//! Density stress test room. 15 NPCs in a 5x3 grid to test perception,
//! snapshot, and tick-budget performance under high entity density.
//! Validates D-026 tick budget holds with many visible entities.
//!
//! Observer position: (16, 16) relative = (96, 94) absolute, facing West.
//!
//! Entities (StableId 34-48):
//! crowd_npc_00..crowd_npc_14 — 15 NPCs in a 5-column x 3-row grid
//! Grid starts at rel (4, 4) = abs (84, 82), spacing: 5x cols, 8y rows.
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 = 80;
const ORIGIN_Y: i32 = 78;
/// Grid layout constants.
const GRID_COLS: usize = 5;
const GRID_ROWS: usize = 3;
const GRID_START_X: i32 = 4;
const GRID_START_Y: i32 = 4;
const GRID_SPACING_X: i32 = 5;
const GRID_SPACING_Y: i32 = 8;
/// WantKind cycle for variety across 15 NPCs.
const WANT_CYCLE: &[WantKind] = &[
WantKind::Wealth,
WantKind::Safety,
WantKind::Knowledge,
WantKind::Connection,
WantKind::Power,
WantKind::Freedom,
WantKind::Justice,
WantKind::Revenge,
WantKind::Happiness,
];
/// Spawn Crowd Plaza entities in canonical order (StableId 34-48).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
let mut index = 0usize;
for row in 0..GRID_ROWS {
for col in 0..GRID_COLS {
let rx = GRID_START_X + col as i32 * GRID_SPACING_X;
let ry = GRID_START_Y + row as i32 * GRID_SPACING_Y;
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let want_kind = WANT_CYCLE[index % WANT_CYCLE.len()];
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity: ((index % 8) + 2) as u8, // 2-9 range
description: format!("Crowd Plaza NPC #{:02}", index),
},
Contentment {
level: (index as i16 * 7 - 50).clamp(-100, 100),
},
ToleranceThreshold {
current_stress: (index as i16 * 5) % 80,
threshold: 40 + (index as i16 % 4) * 10,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
index += 1;
}
}
}
@@ -0,0 +1,88 @@
//! Dialogue Room — Room 6 (28x20)
//!
//! Tests D-041 (knowledge graph), D-028 (dialogue filtering pipeline).
//!
//! Layout: Four NPCs with DialogueProfile components, enabling the full
//! dialogue selection pipeline (access tier, situation, trust, mood scoring).
//! NPCs have varying trust/contentment to test different dialogue branches.
//!
//! Observer position: (14, 10) relative = (50, 114) absolute, facing North.
//!
//! Entities (StableId 30-33):
//! npc_dialogue_a (44, 112) — Relaxed dock worker
//! npc_dialogue_b (50, 110) — Guarded technician
//! npc_dialogue_c (56, 112) — Stressed supervisor
//! npc_dialogue_d (50, 118) — Distant observer (range test)
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
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 = 36;
const ORIGIN_Y: i32 = 104;
/// NPC definitions: (name, rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, location, role).
#[allow(clippy::type_complexity)]
const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[
(
"npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60,
"the-terminal", "dock-worker",
),
(
"npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45,
"the-terminal", "technician",
),
(
"npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50,
"the-terminal", "supervisor",
),
(
"npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70,
"the-terminal", "observer",
),
];
/// Spawn Dialogue Room entities in canonical order (StableId 30-33).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(name, rx, ry, want_kind, intensity, contentment, stress, threshold, location, role) 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!("Dialogue Room test NPC: {}", name),
},
Contentment {
level: contentment,
},
ToleranceThreshold {
current_stress: stress,
threshold,
},
MovementSpeed::default(),
DialogueProfile {
location: location.to_string(),
role: role.to_string(),
},
CurrentMood::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,66 @@
//! Fog Theater — Room 1 (44x32)
//!
//! Tests D-059 (fog layers), D-060 (cognitive delay for fog recognition).
//!
//! Layout: Large open room with NPCs at varying distances from observer.
//! Tests visibility at clear, peripheral, deep-fog, and edge-of-range
//! distances. No internal walls — fog layers are distance-based, not
//! occlusion-based (that's the Occlusion Corridor's job).
//!
//! Observer position: (28, 16) relative = (56, 18) absolute, facing South.
//!
//! Entities (StableId 5-8):
//! npc_fog_clear (56, 22) — 4 tiles south, clear vision cone
//! npc_fog_peripheral (46, 18) — 10 tiles west, peripheral sector
//! npc_fog_deep (34, 10) — far NW corner, deep fog range
//! npc_fog_edge (68, 28) — far SE, edge-of-range test
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 = 28;
const ORIGIN_Y: i32 = 2;
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
("npc_fog_clear", 28, 20, WantKind::Safety, 5),
("npc_fog_peripheral", 18, 16, WantKind::Knowledge, 6),
("npc_fog_deep", 6, 8, WantKind::Freedom, 3),
("npc_fog_edge", 40, 26, WantKind::Wealth, 4),
];
/// Spawn Fog Theater entities in canonical order (StableId 5-8).
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!("Fog Theater 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 @@
//! Interaction Gallery — Room 4 (24x20)
//!
//! Tests D-057 (entity interaction vertical list, verb generation per type).
//!
//! Layout: One entity of each ObjectType to test that each type generates
//! the correct interaction verb(s). Container is already tested in
//! Inventory Warehouse, so this room covers the remaining 5 types.
//!
//! Observer position: (12, 10) relative = (14, 92) absolute, facing East.
//!
//! Entities (StableId 24-28):
//! obj_notice (8, 88) — Readable (Read verb)
//! obj_terminal (8, 94) — Terminal (Use verb)
//! obj_hatch (20, 88) — Door (Open/Close verb)
//! obj_pickup (20, 94) — Pickup (Pick up verb)
//! obj_bench (14, 96) — Furniture (Sit verb)
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
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 = 2;
const ORIGIN_Y: i32 = 82;
/// Object definitions: (name, relative_x, relative_y, object_type).
const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
("obj_notice", 6, 6, ObjectType::Readable),
("obj_terminal", 6, 12, ObjectType::Terminal),
("obj_hatch", 18, 6, ObjectType::Door),
("obj_pickup", 18, 12, ObjectType::Pickup),
("obj_bench", 12, 14, ObjectType::Furniture),
];
/// Spawn Interaction Gallery entities in canonical order (StableId 24-28).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(_name, rx, ry, obj_type) in OBJECTS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((Interactable, obj_type, pos))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
+4
View File
@@ -3,7 +3,11 @@
//! Each room module exports a `spawn_entities()` function that creates
//! entities in canonical order for deterministic StableId assignment.
pub mod crowd_plaza;
pub mod dialogue_room;
pub mod fog_theater;
pub mod hub;
pub mod interaction_gallery;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;
+1
View File
@@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
bridge
+1
View File
@@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
bridge
+12
View File
@@ -8,6 +8,7 @@
use bevy_app::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
@@ -51,6 +52,11 @@ fn content_runtime_boot_tick_10_snapshot() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Barrier keeps the server thread alive until the client has finished
// reading all snapshots, preventing a TCP RST race under parallel execution.
let barrier = Arc::new(Barrier::new(2));
let server_barrier = barrier.clone();
// Server thread: full plugin stack with real content
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
@@ -96,6 +102,9 @@ fn content_runtime_boot_tick_10_snapshot() {
for _ in 0..10 {
app.update();
}
// Wait for client to finish reading before dropping the TCP socket
server_barrier.wait();
});
// Client: connect with read timeout and receive 10 snapshots
@@ -129,6 +138,9 @@ fn content_runtime_boot_tick_10_snapshot() {
drop(reader);
drop(writer);
// Signal server thread that client is done reading
barrier.wait();
// Server thread must not have panicked
server_handle
.join()
+259
View File
@@ -0,0 +1,259 @@
//! Content scaling test (#500, D-026).
//!
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
//! NPCs and compares:
//! 1. Tick timing stays within D-026 budget (100ms)
//! 2. Baseline entities still behave identically (deterministic)
//!
//! Run with: cargo test --test content_scaling -- --nocapture
use bevy_app::prelude::*;
use std::time::Instant;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::BridgePlugin;
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
use settled_reach_server::simulation::interaction::Interactable;
use settled_reach_server::simulation::movement::TilePosition;
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::SimulationPlugin;
/// Number of ticks to run for timing measurements.
const TIMING_TICKS: usize = 50;
/// D-026 budget: 100ms per tick maximum.
const MAX_TICK_MS: f64 = 100.0;
/// Extra NPC counts for scaling tiers.
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
/// Set up a Gauntlet world and return the app.
fn setup_baseline() -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);
app
}
/// Spawn N extra NPCs spread across the Gauntlet hub area.
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
fn spawn_extra_npcs(app: &mut App, count: usize) {
// Remove registry from world so we can mutate it while also spawning entities.
let mut registry = app
.world_mut()
.remove_resource::<EntityRegistry>()
.expect("EntityRegistry should exist after setup_gauntlet");
let cols = 10;
for i in 0..count {
let x = 40 + (i % cols) as i32;
let y = 48 + (i / cols) as i32;
let pos = TilePosition::new(x, y, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 5,
description: format!("extra_npc_{}", i),
},
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));
}
app.insert_resource(registry);
}
/// Tick the app N times and return average milliseconds per tick.
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
// Warm-up tick (first tick has startup overhead)
app.update();
let start = Instant::now();
for _ in 0..ticks {
app.update();
}
let elapsed = start.elapsed();
elapsed.as_secs_f64() * 1000.0 / ticks as f64
}
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
fn count_entities(app: &App) -> usize {
let registry = app.world().resource::<EntityRegistry>();
registry.len() as usize
}
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
#[test]
#[cfg(feature = "gauntlet")]
fn baseline_tick_timing_within_budget() {
let mut app = setup_baseline();
let entity_count = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
eprintln!(
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
entity_count, avg_ms, TIMING_TICKS
);
assert!(
avg_ms < MAX_TICK_MS,
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
avg_ms,
MAX_TICK_MS
);
}
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
/// Tests 0 (baseline), 15, and 50 extra NPCs.
#[test]
#[cfg(feature = "gauntlet")]
fn scaling_tick_timing_within_budget() {
let mut results: Vec<(usize, usize, f64)> = Vec::new();
for &extra_count in EXTRA_NPC_COUNTS {
let mut app = setup_baseline();
if extra_count > 0 {
spawn_extra_npcs(&mut app, extra_count);
}
let total_entities = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
results.push((extra_count, total_entities, avg_ms));
}
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS);
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
eprintln!("{:-<37}", "");
for &(extra, total, avg_ms) in &results {
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
}
// Assert all tiers stay within budget
for &(extra, _total, avg_ms) in &results {
assert!(
avg_ms < MAX_TICK_MS,
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
extra,
avg_ms,
MAX_TICK_MS
);
}
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
if results.len() >= 2 {
let baseline_ms = results[0].2;
let max_extra_ms = results.last().unwrap().2;
let scaling_factor = max_extra_ms / baseline_ms;
eprintln!(
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
results.last().unwrap().0,
scaling_factor
);
assert!(
scaling_factor < 5.0,
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
scaling_factor
);
}
}
/// Determinism test: baseline entities produce identical snapshots regardless
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
/// visibility after the same number of ticks.
#[test]
#[cfg(feature = "gauntlet")]
fn extra_npcs_dont_affect_baseline_behavior() {
// Run baseline
let mut baseline_app = setup_baseline();
for _ in 0..10 {
baseline_app.update();
}
let baseline_buffer = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
// Run with extra NPCs
let mut scaled_app = setup_baseline();
spawn_extra_npcs(&mut scaled_app, 15);
for _ in 0..10 {
scaled_app.update();
}
let scaled_buffer = scaled_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
// Same tick
assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match");
// Same game time
assert_eq!(
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
"game time should match"
);
// Player position should be identical
let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
assert!(baseline_player.is_some(), "baseline should have player");
assert!(scaled_player.is_some(), "scaled should have player");
let bp = baseline_player.unwrap();
let sp = scaled_player.unwrap();
assert_eq!(bp.x, sp.x, "player x should match");
assert_eq!(bp.y, sp.y, "player y should match");
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
// should still be visible in scaled run. Extra NPCs may add to the visible
// set, but shouldn't remove baseline visibility.
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
let baseline_original_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
let scaled_original_ids: Vec<u64> = scaled_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
assert_eq!(
baseline_original_ids, scaled_original_ids,
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
);
}
+2
View File
@@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
}
}
@@ -204,6 +205,7 @@ fn generate_msgpack_fixtures() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
write_fixture(
"snapshot_v2_full",
+4 -1
View File
@@ -1,4 +1,7 @@
{
"blocked_entities": [
2
],
"current_monologue": null,
"dialogue_response": null,
"entities": [
@@ -62,7 +65,7 @@
"player_inventory": [],
"player_stance": "Sprint",
"tick": 8,
"version": 8,
"version": 9,
"visible_tiles": [
{
"tile_kind": "Wall",
+222
View File
@@ -0,0 +1,222 @@
//! Performance benchmarks: tick timing and memory usage
//!
//! Run with: cargo test --release --test perf_bench -- --ignored --nocapture
//! Output: PERF_RESULT:{json} lines for tooling/perf-baseline to parse.
//!
//! Tick budget target: 100ms (D-026)
use bevy_app::prelude::*;
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
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;
const WARMUP_TICKS: usize = 5;
const MEASURE_TICKS: usize = 50;
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
fn read_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("VmRSS:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse().ok())
})
}
/// Full plugin stack tick benchmark with real content.
///
/// Boots the server with production content, runs WARMUP_TICKS to stabilize,
/// then measures MEASURE_TICKS of app.update() wall-clock time. Reports entity
/// counts from observer snapshots and process RSS.
///
/// Protocol contract: BridgePlugin uses non-blocking receive (WouldBlock →
/// empty input vec), so the server always advances even if the client hasn't
/// sent input yet. The server sends a snapshot each tick; the client blocks
/// on read until one arrives, then responds with (empty) input. No deadlock
/// possible — see TcpBridge::receive_inputs and send_snapshot in bridge/tcp.rs.
#[test]
#[ignore]
fn perf_tick_timing() {
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, timed ticks
let server_root = root.clone();
let server_handle = thread::spawn(move || -> Vec<Duration> {
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: server_root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
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);
let mut timings = Vec::with_capacity(TOTAL_TICKS);
for _ in 0..TOTAL_TICKS {
let start = Instant::now();
app.update();
timings.push(start.elapsed());
}
timings
});
// Client: pump protocol — read snapshots, send empty inputs
let stream = TcpStream::connect(server_addr).expect("client connect");
stream
.set_read_timeout(Some(Duration::from_secs(30)))
.expect("set read timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
let mut entity_counts: Vec<usize> = Vec::with_capacity(TOTAL_TICKS);
for tick in 0..TOTAL_TICKS {
// Server may close connection after its last tick — handle gracefully
let payload = match read_framed(&mut reader) {
Ok(Some(p)) => p,
Ok(None) | Err(_) => break,
};
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
entity_counts.push(snapshot.entities.len());
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() {
break;
}
}
// Must have received enough measured snapshots for meaningful results.
// Require all warmup ticks plus at least half the measurement window.
let min_snapshots = WARMUP_TICKS + MEASURE_TICKS / 2;
assert!(
entity_counts.len() >= min_snapshots,
"Only received {} snapshots, need at least {} ({} warmup + {} measured)",
entity_counts.len(),
min_snapshots,
WARMUP_TICKS,
MEASURE_TICKS / 2
);
drop(reader);
drop(writer);
let timings = server_handle
.join()
.expect("server thread panicked during tick benchmark");
// Analyze measured ticks (skip warmup)
let measured_us: Vec<u64> = timings
.iter()
.skip(WARMUP_TICKS)
.map(|d| d.as_micros() as u64)
.collect();
let min = *measured_us.iter().min().unwrap();
let max = *measured_us.iter().max().unwrap();
let sum: u64 = measured_us.iter().sum();
let mean = sum / measured_us.len() as u64;
let mut sorted = measured_us.clone();
sorted.sort();
// Nearest-rank p95: index = floor(0.95 * (N-1)) for 0-based indexing.
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
let p95 = sorted[p95_idx.min(sorted.len() - 1)];
let entity_counts_measured: Vec<usize> =
entity_counts.iter().skip(WARMUP_TICKS).copied().collect();
let avg_entities =
entity_counts_measured.iter().sum::<usize>() / entity_counts_measured.len().max(1);
let max_entities = entity_counts_measured.iter().max().copied().unwrap_or(0);
let rss_kb = read_rss_kb();
let result = serde_json::json!({
"tick_timing": {
"warmup_ticks": WARMUP_TICKS,
"measured_ticks": measured_us.len(),
"min_us": min,
"max_us": max,
"mean_us": mean,
"p95_us": p95,
"all_us": measured_us,
},
"entities": {
"avg_per_snapshot": avg_entities,
"max_per_snapshot": max_entities,
},
"memory": {
"rss_kb": rss_kb,
},
});
println!(
"PERF_RESULT:{}",
serde_json::to_string(&result).unwrap()
);
}
+92 -1
View File
@@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
}
}
@@ -250,6 +251,7 @@ fn snapshot_v2_fields_roundtrip() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -304,7 +306,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 8,
PROTOCOL_VERSION, 9,
"bump this assertion when protocol version changes"
);
}
@@ -342,6 +344,7 @@ fn all_facing_direction_variants_roundtrip() {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -469,6 +472,10 @@ fn v5_payload_deserializes_into_v6_struct() {
decoded.pending_recognitions.is_empty(),
"missing pending_recognitions should default to empty"
);
assert!(
decoded.blocked_entities.is_empty(),
"missing blocked_entities should default to empty"
);
}
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
@@ -1095,6 +1102,90 @@ fn gdscript_generated_fixtures_deserialize() {
eprintln!("Verified {} GDScript-generated fixtures", count);
}
/// blocked_entities Vec<u64> round-trips through MessagePack (#514).
/// Guards the debug field survives serialization.
#[test]
fn blocked_entities_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.blocked_entities = vec![42, 99, 1024];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.blocked_entities,
vec![42, 99, 1024],
"blocked_entities should survive roundtrip"
);
}
/// Empty blocked_entities round-trips correctly (#514).
#[test]
fn blocked_entities_empty_roundtrip() {
let snapshot = test_snapshot(0, vec![]);
assert!(snapshot.blocked_entities.is_empty());
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert!(
decoded.blocked_entities.is_empty(),
"empty blocked_entities should survive roundtrip"
);
}
/// v8 payloads (without blocked_entities) must deserialize into the v9 struct
/// via #[serde(default)]. Guards backwards compat during migration (#514).
#[test]
fn v8_payload_deserializes_into_v9_struct() {
#[derive(serde::Serialize)]
struct ObserverSnapshotV8 {
version: u8,
tick: u64,
game_time: GameTime,
player_facing: FacingDirection,
player_stance: MovementStance,
player_inventory: Vec<InventoryItem>,
entities: Vec<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
pending_recognitions: Vec<PendingRecognitionWire>,
dialogue_response: Option<DialogueResponseEvent>,
}
let v8 = ObserverSnapshotV8 {
version: 8,
tick: 100,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::Walk,
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
};
let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v8 payload should deserialize into v9 struct via serde(default)");
assert_eq!(decoded.version, 8, "version field preserved from v8");
assert_eq!(decoded.tick, 100);
assert!(
decoded.blocked_entities.is_empty(),
"missing blocked_entities should default to empty"
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
+116
View File
@@ -0,0 +1,116 @@
{
"timestamp": "2026-02-18T12:07:18.546680+00:00",
"git": {
"commit": "c1d7c07",
"branch": "ci"
},
"tick_timing": {
"max_us": 363,
"mean_us": 332,
"measured_ticks": 50,
"min_us": 310,
"p95_us": 356,
"warmup_ticks": 5
},
"entities": {
"avg_per_snapshot": 21,
"max_per_snapshot": 21
},
"memory": {
"rss_kb": 6900
},
"shadowcast": {
"configs": [
{
"map_size": 32,
"density": "open field",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 57.86,
"symmetric_per_call_us": 57.86,
"recursive_total_ms": 97.97,
"recursive_per_call_us": 97.97
},
{
"map_size": 32,
"density": "moderate corridors",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 52.29,
"symmetric_per_call_us": 52.29,
"recursive_total_ms": 189.99,
"recursive_per_call_us": 189.99
},
{
"map_size": 32,
"density": "dense rooms",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 21.31,
"symmetric_per_call_us": 21.31,
"recursive_total_ms": 98.47,
"recursive_per_call_us": 98.47
},
{
"map_size": 64,
"density": "open field",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 60.5,
"symmetric_per_call_us": 60.5,
"recursive_total_ms": 98.33,
"recursive_per_call_us": 98.33
},
{
"map_size": 64,
"density": "moderate corridors",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 47.41,
"symmetric_per_call_us": 47.41,
"recursive_total_ms": 184.87,
"recursive_per_call_us": 184.87
},
{
"map_size": 64,
"density": "dense rooms",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 14.35,
"symmetric_per_call_us": 14.35,
"recursive_total_ms": 79.6,
"recursive_per_call_us": 79.6
},
{
"map_size": 150,
"density": "open field",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 56.65,
"symmetric_per_call_us": 56.65,
"recursive_total_ms": 97.8,
"recursive_per_call_us": 97.8
},
{
"map_size": 150,
"density": "moderate corridors",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 40.49,
"symmetric_per_call_us": 40.49,
"recursive_total_ms": 158.81,
"recursive_per_call_us": 158.81
},
{
"map_size": 150,
"density": "dense rooms",
"range": 20,
"iterations": 1000,
"symmetric_total_ms": 12.88,
"symmetric_per_call_us": 12.88,
"recursive_total_ms": 77.98,
"recursive_per_call_us": 77.98
}
]
}
}
@@ -0,0 +1,5 @@
[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveNorth"}]
[{"tick":2,"action":"MoveEast"}]
[{"tick":3,"action":"TeleportToHub"}]
[{"tick":4,"action":"MoveNorth"}]
+5
View File
@@ -0,0 +1,5 @@
[]
[]
[]
[]
[]
+5
View File
@@ -0,0 +1,5 @@
[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveNorth"}]
[{"tick":2,"action":"MoveNorth"}]
[{"tick":3,"action":"MoveNorth"}]
[{"tick":4,"action":"MoveNorth"}]
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Performance baseline tooling.
Runs the server benchmark suite, captures tick timing, memory usage, and entity
count scaling metrics, outputs results to tests/perf/.
Usage:
tooling/perf-baseline Run benchmarks and save baseline
tooling/perf-baseline --compare Compare current run against saved baseline (no save)
Exit code 0 = success, 1 = failure or regression detected (--compare mode).
"""
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PERF_DIR = ROOT / "tests" / "perf"
BASELINE_FILE = PERF_DIR / "baseline.json"
# Tick budget from D-026: 100ms per tick at 10 tps floor (D-031).
# If tick rate changes, update this constant.
TICK_BUDGET_US = 100_000 # 100ms
def run_command(cmd, **kwargs):
"""Run a command in the server directory and return the result."""
return subprocess.run(
cmd, capture_output=True, text=True, cwd=ROOT / "server", **kwargs
)
def get_git_info():
"""Get current git commit and branch."""
commit = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, cwd=ROOT,
).stdout.strip()
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, cwd=ROOT,
).stdout.strip()
return {"commit": commit, "branch": branch}
def run_tick_benchmark():
"""Run perf_tick_timing test and parse PERF_RESULT JSON."""
print(" Running tick timing benchmark (release mode)...")
result = run_command([
"cargo", "test", "--release", "--test", "perf_bench",
"--", "--ignored", "--nocapture", "perf_tick_timing",
])
if result.returncode != 0:
print(f" FAILED: tick benchmark exited {result.returncode}")
if result.stderr:
# Print last 20 lines of stderr for diagnostics
lines = result.stderr.strip().splitlines()
for line in lines[-20:]:
print(f" {line}")
return None
# Parse PERF_RESULT: line from stdout
for line in result.stdout.splitlines():
if line.startswith("PERF_RESULT:"):
json_str = line[len("PERF_RESULT:"):]
return json.loads(json_str)
print(" WARNING: No PERF_RESULT found in test output")
return None
def run_shadowcast_benchmark():
"""Run shadowcast benchmark and parse structured output."""
print(" Running shadowcast benchmark (release mode)...")
result = run_command([
"cargo", "test", "--release", "--test", "shadowcast_bench",
"--", "--ignored", "--nocapture", "benchmark_symmetric_vs_recursive",
])
if result.returncode != 0:
print(f" FAILED: shadowcast benchmark exited {result.returncode}")
return None
configs = []
current = {}
for line in result.stdout.splitlines():
line = line.strip()
m = re.match(
r"Map: (\d+)x(\d+), Density: (.+), Range: (\d+), Iterations: (\d+)",
line,
)
if m:
# New config block — flush previous if complete
if current.get("map_size"):
configs.append(current)
current = {
"map_size": int(m.group(1)),
"density": m.group(3),
"range": int(m.group(4)),
"iterations": int(m.group(5)),
}
continue
m = re.match(r"Symmetric:\s+([0-9.]+)ms total, ([0-9.]+).s/call", line)
if m:
current["symmetric_total_ms"] = float(m.group(1))
current["symmetric_per_call_us"] = float(m.group(2))
continue
m = re.match(r"Recursive:\s+([0-9.]+)ms total, ([0-9.]+).s/call", line)
if m:
current["recursive_total_ms"] = float(m.group(1))
current["recursive_per_call_us"] = float(m.group(2))
continue
# Flush last config
if current.get("map_size"):
configs.append(current)
return {"configs": configs} if configs else None
def compare_baselines(old, new):
"""Compare two baselines and report regressions. Returns list of regression strings."""
regressions = []
improvements = []
old_tick = old.get("tick_timing", {})
new_tick = new.get("tick_timing", {})
if old_tick and new_tick:
# Mean tick time regression (>20% = warning)
old_mean = old_tick.get("mean_us", 0)
new_mean = new_tick.get("mean_us", 0)
if old_mean > 0:
change = (new_mean - old_mean) / old_mean * 100
if change > 20:
regressions.append(
f"mean tick time {old_mean}us -> {new_mean}us (+{change:.1f}%)"
)
elif change < -20:
improvements.append(
f"mean tick time {old_mean}us -> {new_mean}us ({change:.1f}%)"
)
# p95 tick time regression
old_p95 = old_tick.get("p95_us", 0)
new_p95 = new_tick.get("p95_us", 0)
if old_p95 > 0:
change = (new_p95 - old_p95) / old_p95 * 100
if change > 20:
regressions.append(
f"p95 tick time {old_p95}us -> {new_p95}us (+{change:.1f}%)"
)
elif change < -20:
improvements.append(
f"p95 tick time {old_p95}us -> {new_p95}us ({change:.1f}%)"
)
# Absolute budget check
new_p95 = new.get("tick_timing", {}).get("p95_us", 0)
if new_p95 > TICK_BUDGET_US:
regressions.append(
f"p95 {new_p95}us exceeds {TICK_BUDGET_US}us tick budget (D-026)"
)
return regressions, improvements
def main():
compare_mode = "--compare" in sys.argv
print("=== Performance Baseline ===\n")
# Build in release mode first
print("Building server (release)...")
build = run_command(["cargo", "build", "--release"])
if build.returncode != 0:
print("BUILD FAILED")
lines = build.stderr.strip().splitlines()
for line in lines[-20:]:
print(f" {line}")
return 1
print("\nRunning benchmarks...\n")
tick_results = run_tick_benchmark()
shadowcast_results = run_shadowcast_benchmark()
if not tick_results:
print("\nFATAL: tick benchmark failed -- no baseline generated")
return 1
# Assemble baseline
git_info = get_git_info()
baseline = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"git": git_info,
"tick_timing": tick_results.get("tick_timing", {}),
"entities": tick_results.get("entities", {}),
"memory": tick_results.get("memory", {}),
}
if shadowcast_results:
baseline["shadowcast"] = shadowcast_results
# Report
tt = baseline["tick_timing"]
print(f"\n--- Results ---")
print(f"Git: {git_info['commit']} ({git_info['branch']})")
print(f"Tick timing ({tt.get('measured_ticks', '?')} ticks, "
f"{tt.get('warmup_ticks', '?')} warmup):")
print(f" min: {tt.get('min_us', '?')}us")
print(f" mean: {tt.get('mean_us', '?')}us")
print(f" p95: {tt.get('p95_us', '?')}us")
print(f" max: {tt.get('max_us', '?')}us")
ent = baseline["entities"]
print(f"Entities: avg {ent.get('avg_per_snapshot', '?')}, "
f"max {ent.get('max_per_snapshot', '?')}")
mem = baseline["memory"]
rss = mem.get("rss_kb")
if rss:
print(f"Memory: {rss} KB RSS ({rss / 1024:.1f} MB)")
if shadowcast_results:
n = len(shadowcast_results.get("configs", []))
print(f"Shadowcast: {n} configurations benchmarked")
# Budget check
p95 = tt.get("p95_us", 0)
if p95 > TICK_BUDGET_US:
print(f"\nBUDGET EXCEEDED: p95 {p95}us > {TICK_BUDGET_US}us (D-026)")
else:
budget_pct = p95 / TICK_BUDGET_US * 100 if TICK_BUDGET_US else 0
print(f"\nBudget: {budget_pct:.1f}% of {TICK_BUDGET_US}us tick budget (D-026)")
# Compare with previous baseline if it exists
if BASELINE_FILE.exists():
with open(BASELINE_FILE) as f:
old_baseline = json.load(f)
old_commit = old_baseline.get("git", {}).get("commit", "?")
print(f"\n--- Comparison vs {old_commit} ---")
regressions, improvements = compare_baselines(old_baseline, baseline)
for r in regressions:
print(f" REGRESSION: {r}")
for i in improvements:
print(f" IMPROVEMENT: {i}")
if not regressions and not improvements:
print(" No significant changes.")
if compare_mode and regressions:
print(f"\n{len(regressions)} regression(s) detected.")
return 1
elif compare_mode:
print(f"\nERROR: --compare requires a saved baseline at {BASELINE_FILE.relative_to(ROOT)}")
print("Run `make perf-baseline` first to create one.")
return 1
if compare_mode:
return 0
# Save baseline (strip per-tick array — too noisy for git diffs)
PERF_DIR.mkdir(parents=True, exist_ok=True)
committed = json.loads(json.dumps(baseline))
committed["tick_timing"].pop("all_us", None)
with open(BASELINE_FILE, "w") as f:
json.dump(committed, f, indent=2)
f.write("\n")
print(f"\nBaseline written to {BASELINE_FILE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+44 -1
View File
@@ -556,6 +556,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -799,6 +809,12 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.29"
@@ -1067,6 +1083,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1143,7 +1172,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.0"
version = "0.1.9"
dependencies = [
"bevy_app",
"bevy_ecs",
@@ -1168,6 +1197,7 @@ dependencies = [
"serde",
"serde_json",
"settled-reach-server",
"tempfile",
]
[[package]]
@@ -1241,6 +1271,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.18"
+3
View File
@@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
[dev-dependencies]
tempfile = "3"
+153
View File
@@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput;
use std::path::Path;
/// Load a JSONL replay file. Returns one Vec<PlayerInput> per tick.
///
/// Format: one JSON array per line. Each array contains PlayerInput objects
/// for that tick. Blank lines are skipped. Returns Err with line number on
/// parse failure.
pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?;
@@ -22,3 +26,152 @@ pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
}
Ok(ticks)
}
#[cfg(test)]
mod tests {
use super::*;
use settled_reach_server::bridge::types::PlayerAction;
use std::io::Write;
fn write_temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn load_single_tick_single_action() {
let f = write_temp_file(r#"[{"tick":0,"action":"MoveNorth"}]"#);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 1);
assert_eq!(ticks[0].len(), 1);
assert!(ticks[0][0].action.is_movement());
}
#[test]
fn load_multiple_ticks() {
let content = r#"[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveEast"}]
[{"tick":2,"action":"MoveSouth"}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 3);
}
#[test]
fn load_multiple_actions_per_tick() {
let content = r#"[{"tick":0,"action":"MoveNorth"},{"tick":0,"action":"Pause"}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 1);
assert_eq!(ticks[0].len(), 2);
}
#[test]
fn load_empty_array_idle_tick() {
let content = "[]";
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 1);
assert!(ticks[0].is_empty());
}
#[test]
fn blank_lines_skipped() {
let content = r#"[{"tick":0,"action":"MoveNorth"}]
[{"tick":2,"action":"MoveSouth"}]
"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 2, "blank lines should be skipped, not counted as ticks");
}
#[test]
fn empty_file_returns_empty_vec() {
let f = write_temp_file("");
let ticks = load_replay(f.path()).unwrap();
assert!(ticks.is_empty());
}
#[test]
fn whitespace_only_file_returns_empty_vec() {
let f = write_temp_file(" \n \n\n ");
let ticks = load_replay(f.path()).unwrap();
assert!(ticks.is_empty());
}
#[test]
fn invalid_json_reports_line_number() {
let content = r#"[{"tick":0,"action":"MoveNorth"}]
not valid json
[{"tick":2,"action":"MoveSouth"}]"#;
let f = write_temp_file(content);
let err = load_replay(f.path()).unwrap_err();
assert!(err.contains("replay line 2"), "error should reference line 2, got: {}", err);
}
#[test]
fn missing_file_returns_error() {
let err = load_replay(Path::new("/nonexistent/replay.jsonl")).unwrap_err();
assert!(err.contains("failed to read replay file"), "got: {}", err);
}
#[test]
fn interact_action_parses() {
let content =
r#"[{"tick":0,"action":{"Interact":{"target_entity_id":42,"verb":"Talk"}}}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 1);
match &ticks[0][0].action {
PlayerAction::Interact {
target_entity_id,
verb,
} => {
assert_eq!(*target_entity_id, Some(42));
assert_eq!(verb.as_deref(), Some("Talk"));
}
other => panic!("expected Interact, got {:?}", other),
}
}
#[test]
fn teleport_to_hub_parses() {
let content = r#"[{"tick":0,"action":"TeleportToHub"}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 1);
assert!(matches!(ticks[0][0].action, PlayerAction::TeleportToHub));
}
#[test]
fn walk_away_parses() {
let content = r#"[{"tick":0,"action":"WalkAway"}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert!(matches!(ticks[0][0].action, PlayerAction::WalkAway));
}
#[test]
fn mixed_replay_scenario() {
// Simulates a realistic Gauntlet replay: move, idle, interact, move, teleport
let content = r#"[{"tick":0,"action":"MoveNorth"}]
[{"tick":1,"action":"MoveNorth"}]
[]
[{"tick":3,"action":{"Interact":{"target_entity_id":100,"verb":"Talk"}}}]
[{"tick":4,"action":"MoveEast"}]
[{"tick":5,"action":"TeleportToHub"}]"#;
let f = write_temp_file(content);
let ticks = load_replay(f.path()).unwrap();
assert_eq!(ticks.len(), 6);
assert_eq!(ticks[0].len(), 1); // MoveNorth
assert_eq!(ticks[1].len(), 1); // MoveNorth
assert_eq!(ticks[2].len(), 0); // Idle
assert_eq!(ticks[3].len(), 1); // Interact
assert_eq!(ticks[4].len(), 1); // MoveEast
assert_eq!(ticks[5].len(), 1); // TeleportToHub
}
}
+2 -2
View File
@@ -472,8 +472,8 @@ class ContentIndex:
if pair in checked:
continue
checked.add(pair)
if target in npc_rels and cid not in npc_rels.get(target, set()):
print(f"XREF WARNING: {cid} has relationship to {target} but no reciprocal found")
if target in self.npcs and cid not in npc_rels.get(target, set()):
print(f"XREF WARNING: {cid} has relationship to {target} but {target} has no reciprocal entry")
warnings += 1
return warnings