Compare commits

..
28 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 f7e5b7eb63 chore(meta): release v0.1.11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:13:56 +01:00
jpmschweitzerandClaude Opus 4.6 6cd82f1c63 chore(db): backup database after worktree merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:10:43 +01:00
jpmschweitzer f4548d407a Merge remote-tracking branch 'origin/planning' 2026-02-19 13:10:30 +01:00
jpmschweitzer 6ef239fd6e Merge remote-tracking branch 'origin/audio' 2026-02-19 13:10:26 +01:00
jpmschweitzerandClaude Opus 4.6 21b7db9ea5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 b3273599a5 docs(sprints): add Sprint 12 Build briefings
50 tickets across 5 teams: server (tier system, KG boundaries,
sound events), client (audio, fog indicators), copy (all v0.1
content), visual (grammar doc, placeholder spec), ci (tooling).
Sprint goal: production-layer foundations + complete v0.1 copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:06:30 +01:00
jpmschweitzerandClaude Opus 4.6 84b6e259bc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:52:09 +01:00
jpmschweitzerandClaude Opus 4.6 f0bab3999d chore(skills): rename all skills to domain-action convention
Adopts the whatsinagame naming pattern where the domain comes
first and the action second (e.g. pr-review, sprint-start,
audio-gen). Updated all cross-references in settings, agents,
docs, and inter-skill references.

12 renames: commit→git-commit, create-skill→skill-create,
gen-audio→audio-gen, gen-image→image-gen, plan-sprint→sprint-plan,
push-pr→pr-push, render-sprite→sprite-gen, review-pr→pr-review,
search-docs→docs-search, start-sprint→sprint-start,
start-workshop→workshop-start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:51:56 +01:00
jpmschweitzer 324caab244 Merge remote-tracking branch 'origin/client' 2026-02-19 12:37:28 +01:00
jpmschweitzer 8e879b340a Merge remote-tracking branch 'origin/server' 2026-02-19 12:37:25 +01:00
jpmschweitzerandClaude Opus 4.6 258b266f15 fix(simulation): address PR #39 review — 3 warnings + 6 suggestions
Warnings fixed:
- contraband.rs: scan event now always emits even when NPC already
  knows (was skipped by early `continue`). Contract matches doc.
- test_world/mod.rs: ScanEventBuffer added to player spawn bundle
  so check_contraband_scan doesn't silently no-op in gauntlet mode.
- npc/mod.rs → simulation/mod.rs: moved check_contraband_scan
  registration to SimulationPlugin (operates on player inventory and
  snapshot pipeline, consistent with process_talk_interaction).

Suggestions addressed:
- cross_room_transitions.rs T1: clarified standalone position vs
  constants.rs observer position in comment.
- dialogue.rs: Vec<&str> dedup replaced with BTreeSet<&str> for
  deterministic iteration (project convention).
- contraband.rs: added test for multiple simultaneous ScanAuthority
  NPCs in range (564 tests total).
- dialogue.rs: doc-comment on relationship_to_trust explaining
  KnowledgeConfidence ordering and Suspects default.
- cross_room_transitions.rs T5: noted direct KG API usage vs full
  perception system.
- sprint_gauntlet.rs: documented intentional Contentment { level: 0 }.
- content_scaling.rs: noted GAUNTLET_NPC_COUNT is manually maintained.
- contraband.rs: doc-comment on cross-plugin registration rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:23:02 +01:00
jpmschweitzerandClaude Opus 4.6 44d9941f04 fix(client): address PR #40 review — 13 suggestions
- Rename _insert to insert_state in main.gd (Hoshe #1)
- Add after_each() to test_bug_report_ring_buffer.gd for GameState
  cleanup on assertion failure (Hoshe #2)
- Add after_each() to test_insert_off_behavior.gd for stance/interaction
  restore on assertion failure (Hoshe #3)
- Fix assertion message: "unknown" → "unavailable" (Hoshe #4)
- Document memory ceiling of 60 JSON snapshots in ring buffer (Hoshe #5)
- Add precision warning for u64 rng_seed via JSON float (Hoshe #6)
- Promote _action_enum_to_wire to public action_enum_to_wire (Tyre #1)
- Add @warning_ignore for unused _tick parameter (Tyre #5)
- Document insert_active assumption for future no-insert characters (Tyre #4)
- Restructure OQ-07 decision amendments as bullet points (Tyre #7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:19:41 +01:00
jpmschweitzerandClaude Opus 4.6 590041a1bb chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:46 +01:00
jpmschweitzerandClaude Opus 4.6 f8ae36275d chore(config): add .tmp/ repo directory for agent temp files
Gitignored .tmp/ avoids Bash permission prompts when writing
temp files for tea comment posting. Added sed -n blanket
permission. Updated review-pr skill to use Write tool into
.tmp/ instead of Bash heredocs to /tmp/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:17:34 +01:00
jpmschweitzerandClaude Opus 4.6 cf8a4922e5 docs(client): sprint 11 test plans and reports
Test plan for #507/#522, preliminary review of #522, and final
combine test report. All tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:52 +01:00
jpmschweitzerandClaude Opus 4.6 2d9bc33760 test(client): add tests for ring buffer and insert-off behavior (#507, #522)
33 tests: 17 for ring buffer (capacity, overwrite, JSONL format, seed),
16 for insert-off behavior (diegetic test, cursor transitions, cross-system).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:47 +01:00
jpmschweitzerandClaude Opus 4.6 c96f1463dc feat(client): WRONG button 60-tick ring buffer captures (#507)
Upgrades bug_report_dialog.gd from single-tick MVP to 60-tick rolling
history. Pre-allocated ring buffers for inputs and snapshots. Outputs
inputs.jsonl (replay-compatible), snapshots.jsonl, and seed.txt on F12.
Inter-frame input accumulation ensures no inputs lost between server ticks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:42 +01:00
jpmschweitzerandClaude Opus 4.6 931399f248 feat(client): resolve OQ-07 — insert-off suppresses verb labels (#522)
Option (a): cursor shape still changes (body orients to targets), but
verb labels and interaction prompts are suppressed when insert_active
is false. Amends D-056 and D-057 with resolution note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:05:36 +01:00
jpmschweitzerandClaude Sonnet 4.6 bee93963d9 test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)
Cross-room transition tests (server/tests/cross_room_transitions.rs):
- T1: sprint suppresses interaction buffer, restores on Walk (D-055)
- T2: CarriedBy survives room transition — no TilePosition leak (D-065)
- T3: pause mid-corridor discards movement, Unpause resumes (D-031)
- T4: KnowledgeGraph persists across player position change (D-041)
- T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060)
- T6: eavesdrop cut immediately on first movement out of corner (D-071)
- T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070)
- T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071)

All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint).
Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin.

Test suite expansion:
- content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513)
- golden/proof_room_tick_10.json: updated golden file for gauntlet world changes
- golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to
  new world entity count and wire types
- gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:04:29 +01:00
jpmschweitzerandClaude Sonnet 4.6 d103e445e7 feat(simulation): contraband detection + dialogue confidence gate (D-075) (#425, #524)
Contraband detection (Task #425, D-065):
- New module: simulation/contraband.rs — NPC scan checks carried items
  against the KnowledgeGraph confidence gate. NPCs with Authority access
  can initiate a scan; scan outcome depends on item CarriedBy + KG entry.
- Adds ContrabandScanResult event type and ContrabanEntry component.
- Wired into simulation/mod.rs module list.

Dialogue confidence gate (Task #524, D-075 — OQ-18 resolution):
- relationship_to_trust() gains confidence parameter (KnowledgeConfidence).
- Trust tier mapping: (Friendly, KnowsDetails+)→Secret, (Friendly|Known,
  KnowsOf+)→Real, otherwise Surface. Access tier (Layer 1) unchanged.
- Caller process_talk_interaction passes observer KG confidence_of target.
- Resolves OQ-18: confidence co-gates TrustTier, not AccessTier.

Supporting changes:
- decisions/content.md: add D-075 (16 decisions, dated 2026-02-19)
- knowledge/types.rs: expose KnowledgeConfidence comparison helpers
- knowledge/registry.rs: minor API polish
- bridge/types.rs: ContrabandScanResult wire type
- bridge/text_renderer.rs: render contraband scan status
- perception/observer: include carried item count in snapshot
- npc/mod.rs: NPC scan range constant, authority flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:12:24 +01:00
jpmschweitzerandClaude Sonnet 4.6 54754e4080 feat(simulation): add Sprint 11 gauntlet rooms — Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage (#504)
Adds three new test rooms to the gauntlet layout at coordinates that tile
correctly with the existing Sprint 11 world. All rooms follow the canonical
entity-registration pattern (StableId 55-62 assigned in spawn order).

- sprint_gauntlet.rs: 32×22 room (StableId 55-57) — Npc_pacing, Npc_guard,
  Readable sign. Validates sprint suppression (D-055) at close range.
- eavesdrop_alcove.rs: 24×16 room (StableId 58-60) — two NPC speakers,
  corner Readable marker. Validates eavesdrop positioning (D-071).
- confrontation_stage.rs: 32×24 room (StableId 61-62) — Npc_target and
  peripheral passer-by NPC. Validates confrontation verb range (D-070).

Updated test_world: mod.rs registers all new rooms, constants.rs adds GAUNTLET
region constant and room spawn points, reset.rs clears all gauntlet rooms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 6f9a53cb1f docs(briefings): add Sprint 11 Combine briefings
Server (5 tickets), client (2 tickets), and joint coordination
briefings for the Gauntlet completion sprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:54:08 +01:00
jpmschweitzerandClaude Opus 4.6 7282c46db0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:01 +01:00
jpmschweitzerandClaude Opus 4.6 b95e82d17f chore(skills): update gen-audio skill with batch workflow and --post docs
Documents audio-batch manifest format, synth parameters, and the
--post flag as the preferred workflows. Batch reduces approval
count from ~30 to 2 for multi-asset generation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:38 +01:00
jpmschweitzerandClaude Opus 4.6 9f7214dcd6 feat(db): add audio-batch processor and --post flag to audio-generate
Reduces prompt approvals from ~30 per batch to 2 (one manifest write,
one batch run). audio-generate --post chains trim+normalize+convert
after generation. audio-batch processes a JSON manifest of multiple
assets sequentially, supporting both SAO generation and harmonic
synthesis methods. Includes --dry-run, --only, and --skip-existing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:11:33 +01:00
jpmschweitzerandClaude Opus 4.6 a3a311b3bd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:41 +01:00
jpmschweitzerandClaude Opus 4.6 cfbfa332a2 docs(audio): update asset documentation for Sprint 10 deliverables
Updated ambient.md, sfx.md, and ui.md with generation parameters,
frequency specs, and integration notes for all 8 new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:14 +01:00
jpmschweitzerandClaude Opus 4.6 b86457f257 feat(audio): add 8 minimum viable audio assets (#327)
4 ambient loops (SAO, organic aesthetic, ~43s crossfade loops):
  amb_station_base, amb_workplace_layer, amb_bar_layer, amb_corridor_layer
2 footsteps (SAO hybrid, organic aesthetic, 0.3s):
  sfx_footstep_metal_walk, sfx_footstep_metal_run
2 monologue chimes (manual synthesis, insert-tech aesthetic, 0.8s):
  sfx_monologue_chime, sfx_monologue_chime_urgent

Chimes replace Sprint 9 SAO placeholders with production-quality
manual synthesis per D-074. All assets LUFS -16 normalized.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:55:07 +01:00
105 changed files with 5648 additions and 414 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ Synthesize findings.
### Qatux (Documenter & Librarian)
- Core team member — participates in discussion rounds as documenter
- Manages document search via `/search-docs` skill
- Manages document search via `/docs-search` skill
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
- Answers "did we discuss this?" with citations
+1 -1
View File
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
- Provide "state of the project" summaries when asked
### Knowledge management
- Maintain the Qdrant document index via /search-docs skill
- Maintain the Qdrant document index via /docs-search skill
- Update briefing files when decisions change
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
- Catch staleness in briefings and flag for update
+3 -2
View File
@@ -50,10 +50,11 @@
"Bash(find *)",
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Skill(commit)",
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(start-sprint)"
"Skill(sprint-start)"
],
"deny": [
"Bash(git push --force *)",
+255
View File
@@ -0,0 +1,255 @@
---
name: audio-gen
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate a single asset (WAV only)
db/connectors/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/output.wav
# Generate + post-process in one command (WAV → trim → normalize → OGG)
db/connectors/audio-generate "prompt text" \
--duration 10 --steps 100 --cfg 7 \
--output path/to/gen/intermediate.wav \
--output-ogg client/assets/audio/final.ogg
# Batch-generate from a manifest (preferred for multiple assets)
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
| `--post` | off | — | Run trim + normalize + convert after generation. |
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Batch Workflow (Preferred)
For generating multiple assets, use a manifest file. This reduces prompt
approvals to 2: one Write (manifest) + one Bash (batch run).
### 1. Create the manifest
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
```json
{
"description": "Sprint 10 ambient + world SFX batch",
"output_dir": "client/assets/audio",
"gen_dir": "client/assets/audio/gen",
"defaults": {
"steps": 100,
"cfg": 7,
"lufs": -16,
"quality": 6
},
"assets": [
{
"id": "AMB-001",
"filename": "amb_station_base.ogg",
"method": "sao",
"duration": 45,
"steps": 150,
"cfg": 5,
"prompt": "[sonic family prefix] + [template] + [description]"
},
{
"id": "UI-005",
"filename": "sfx_monologue_chime.ogg",
"method": "synth",
"synth": {
"type": "harmonic",
"duration": 0.8,
"fundamental": 1200,
"harmonics": [
{"freq": 2400, "db": -12},
{"freq": 3600, "db": -24}
],
"attack_ms": 15,
"sustain_ratio": 0.2,
"decay": "exponential"
}
}
]
}
```
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
### 2. Run the batch
```bash
# Full run
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
# Dry run — preview what would be generated
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
# Generate only specific assets
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
# Skip assets that already have OGG files
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
```
### 3. Update asset docs with prompts
After the batch completes, write the exact prompts used back into the
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
what was generated; the asset docs record what we have.
### Manifest fields
| Field | Required | Notes |
|-------|----------|-------|
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
| `filename` | yes | Output filename (must match asset doc) |
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
| `duration` | SAO only | Duration in seconds |
| `prompt` | SAO only | Full assembled prompt |
| `steps` | no | Override default steps |
| `cfg` | no | Override default CFG |
| `synth` | synth only | Synthesis parameters (see below) |
### Synth parameters
| Field | Default | Notes |
|-------|---------|-------|
| `type` | harmonic | Only `harmonic` supported currently |
| `duration` | — | Duration in seconds |
| `fundamental` | — | Fundamental frequency in Hz |
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
| `attack_ms` | 10 | Attack time in milliseconds |
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
| `decay` | exponential | `exponential` or `linear` |
## Single Asset Workflow
For one-off generation or iteration on a specific asset:
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
2. Read `references/sonic-palette.md` for the sonic family prefix.
3. Read `references/category-templates.md` for the matching template.
4. Assemble the full prompt.
5. Run `db/connectors/audio-health` to verify the API is up.
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
generate and post-process in one step.
7. Verify the output (file size, duration).
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate (post-processing is already done if `--post` was
used).
## Post-Processing (Standalone)
If you need to post-process separately (e.g., re-normalizing an existing file):
```bash
# Full pipeline: trim → normalize → convert
db/connectors/audio-post pipeline input.wav --output output.ogg
# Individual steps
db/connectors/audio-post trim input.wav
db/connectors/audio-post normalize input.wav --lufs -16
db/connectors/audio-post convert input.wav --output output.ogg
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
or the batch manifest's `method: "synth"` with harmonic parameters.
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
impulse), write a custom script in `tooling/` following the pattern in
`tooling/synth_ui_sounds.py`.
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
AudioManager discovers assets by directory scan — filenames must match exactly.
@@ -1,9 +1,9 @@
---
name: search-docs
name: docs-search
description: >
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
-187
View File
@@ -1,187 +0,0 @@
---
name: gen-audio
description: >
Generate audio assets for The Settled Reach using the Stable Audio Open API
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
sound asset from docs/assets/audio/. Also use when the user asks about audio
generation, sound design pipeline, or audio asset iteration. Triggers on:
"generate audio", "make sounds", "create ambient", "audio pipeline",
"generate sfx", "stable audio", "gen audio", "sound design".
---
# Audio Generation — The Settled Reach
Generate sonically consistent audio assets using the Stable Audio Open API via
wrapper scripts at `db/connectors/audio-*`.
Asset descriptions, filenames, bus routing, and design intent are documented in
`docs/assets/audio/`. This skill provides the prompt system, generation
workflow, and quality validation.
## API Access
**Never call the API directly.** Use the wrapper scripts:
```bash
# Check API health
db/connectors/audio-health
# Generate audio
db/connectors/audio-generate "prompt text" \
--duration 10 \
--steps 100 \
--cfg 7 \
--output path/to/output.wav \
--timeout 600
```
### Parameters
| Parameter | Default | Range | Notes |
|-----------|---------|-------|-------|
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
| `--output` | auto | — | Output file path. Auto-names from prompt if omitted. |
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
### Critical Constraints
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
generation at a time. Always wait for a generation to complete before
starting the next. Sequential only.
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
Be patient. The timeout default (600s) is generous.
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
clips and crossfade-stitch in post-processing.
- **Output is WAV at 44.1kHz stereo.** Convert to .ogg for Godot import:
`ffmpeg -i input.wav -c:a libvorbis -q:a 6 output.ogg`
## Prompt Assembly
Every generation uses three parts:
```
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
```
Never call the API with just the asset description. Always prepend the sonic
family prefix and matching category template.
- **Sonic palette and families:** Read `references/sonic-palette.md`
- **Category templates:** Read `references/category-templates.md` and match by
asset type (ambient, sfx, ui)
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
## Single Asset Workflow
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
duration, bus, method, and design intent.
2. Read `references/sonic-palette.md` for the sonic family prefix.
3. Read `references/category-templates.md` for the matching template.
4. Assemble the full prompt.
5. Run `db/connectors/audio-health` to verify the API is up.
6. Run `db/connectors/audio-generate` with the assembled prompt. **One request
at a time. Wait for completion.**
7. Listen to the output (or describe it based on file size/duration).
8. If acceptable, convert to .ogg and place in `client/assets/audio/`.
9. Update the asset status in `docs/assets/audio/{category}.md`.
## Iteration Workflow
For each asset, generate 4-6 candidates:
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
adjust CFG between 5-9). Run each generation sequentially — never in
parallel.
2. **Solo test** — does each candidate sound right alone?
3. **Stack test** — play the candidate alongside other layers. Does it mask or
clash?
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
jarring repeat?
5. **Close-your-eyes test** — does it create a mental image or sensation?
6. Select the best candidate, trim, normalize, convert.
## Post-Processing
After selecting the best generation:
```bash
# Trim silence from start/end
ffmpeg -i input.wav -af "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse" trimmed.wav
# LUFS normalize to -16 LUFS (broadcast standard, good for game audio)
ffmpeg -i trimmed.wav -af loudnorm=I=-16:LRA=11:TP=-1 normalized.wav
# Convert to .ogg for Godot
ffmpeg -i normalized.wav -c:a libvorbis -q:a 6 output.ogg
# For loops: verify loop point
ffplay -loop 0 output.ogg
```
For ambient loops, create crossfade overlap:
```bash
# Create a 45s loop with 3s crossfade overlap
# (manual: export 48s, crossfade first 3s with last 3s in Audacity)
```
## Manual Synthesis (Insert-Tech Sounds)
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
produce meaningful output. Use manual synthesis instead:
```python
# Example: 50ms cursor hover tick
import numpy as np
import wave
sr = 44100
duration = 0.05 # 50ms
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
freq = 3200 # Hz
signal = np.sin(2 * np.pi * freq * t)
envelope = np.exp(-t * 80) # exponential decay
audio = (signal * envelope * 32767).astype(np.int16)
with wave.open("cursor_hover.wav", "w") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(sr)
f.writeframes(audio.tobytes())
```
## Quality Checklist
After generating, verify:
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
- Frequency range doesn't mask other layers (check docs/assets/audio/palette.md)
- Duration matches spec
- No unwanted artifacts (clicks, pops, digital noise at start/end)
- Loop point is clean (ambient loops only)
- Volume sits well relative to other assets (LUFS normalized)
- Passes the close-your-eyes test
## File Placement
Generated assets go to `client/assets/audio/` with exact filenames from the
asset docs:
```
client/assets/audio/
amb_station_base.ogg # Ambient bus
amb_workplace_layer.ogg # Ambient bus
amb_bar_layer.ogg # Ambient bus
amb_corridor_layer.ogg # Ambient bus
sfx_footstep_metal.ogg # Player Actions bus
sfx_footstep_metal_run.ogg # Player Actions bus
cursor_hover.ogg # UI Sounds bus
implant_open.ogg # UI Sounds bus
fog_recognition.ogg # UI Sounds bus
weapon_aim.ogg # UI Sounds bus
sfx_monologue_chime.ogg # UI Sounds bus
sfx_monologue_chime_urgent.ogg # UI Sounds bus
```
AudioManager discovers these by directory scan — filenames must match exactly.
@@ -1,8 +1,8 @@
---
name: commit
name: git-commit
description: >
Commit changes with clean, structured messages. Use when the user says
"commit", "save my work", "commit changes", or invokes /commit. Enforces
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
conventional commit format, groups changes into logical commits, and maintains
CHANGELOG.md. Never squash unrelated changes into one commit.
user-invocable: true
@@ -1,5 +1,5 @@
---
name: gen-image
name: image-gen
description: >
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
@@ -1,9 +1,9 @@
---
name: push-pr
name: pr-push
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
@@ -41,7 +41,7 @@ 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
first. Use the `/git-commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
proceeding.
@@ -136,5 +136,5 @@ already `done`, `review`, `cancelled`, or `backlog` (only transition
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
PR title instead of generating one.
@@ -1,9 +1,9 @@
---
name: review-pr
name: pr-review
description: >
Review a branch diff with team-appropriate agents before merge. Use when the
user says "review-pr", "review this PR", "review this branch", or invokes
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
audio) in parallel. Reports approve/reject with inline comments.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Task
@@ -156,19 +156,16 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
```bash
# Step 1: Write review to temp file (use Bash heredoc, NOT the Write tool)
cat > /tmp/review-<branch>.md << 'EOF'
## Review: <branch> -> main
...review content...
EOF
```
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
# Step 2: Post to Gitea (separate Bash call)
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
```
These MUST be separate sequential Bash calls — do not chain them in a single
command. The heredoc write must complete before `tea comment` reads the file.
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
directory is gitignored and exists in the repo root for this purpose.
## 7. Merging approved PRs
@@ -1,5 +1,5 @@
---
name: create-skill
name: skill-create
description: >
Guidance for creating effective Claude Code skills (.skill packages).
Use when the user wants to create, build, design, or iterate on a skill —
@@ -1,9 +1,9 @@
---
name: plan-sprint
name: sprint-plan
description: >
Plan the next sprint and generate team briefing files. Use when the user says
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
proposes ticket selection, and writes briefing files per team to
docs/sprints/sprint-N/.
user-invocable: true
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
```
Task(
subagent_type: "si",
prompt: "Run /plan-sprint for Sprint N. Read the skill at
.claude/skills/plan-sprint/SKILL.md for the full workflow,
prompt: "Run /sprint-plan for Sprint N. Read the skill at
.claude/skills/sprint-plan/SKILL.md for the full workflow,
then execute it. Use the arguments provided: {args}",
description: "Plan sprint N"
)
@@ -1,9 +1,9 @@
---
name: start-sprint
name: sprint-start
description: >
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:
server/client/copy", "begin sprint", or invokes /sprint-start. 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.
@@ -34,7 +34,7 @@ the team branch workflow (steps 28).
## Main branch workflow (sprint lifecycle management)
When `/start-sprint` is run on `main`, assess the current sprint state
When `/sprint-start` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
@@ -97,7 +97,7 @@ git push && git push --tags
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`.
running `/sprint-plan`.
---
@@ -115,7 +115,7 @@ A sprint is ready to activate. Verify it looks complete:
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/plan-sprint` to complete planning.
and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
@@ -125,14 +125,14 @@ 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`
- Remind the user to switch to a team branch and run `/sprint-start`
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`
Nothing is ready. Report the state and suggest running `/sprint-plan`
to plan the next sprint.
---
@@ -167,7 +167,7 @@ If no active sprint is found, report that and stop.
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/plan-sprint` to generate one.
`/sprint-plan` to generate one.
### 5. Load ticket details
@@ -1,11 +1,11 @@
---
name: render-sprite
name: sprite-gen
description: >
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
east, south, west) with outline applied at working resolution. Use when the
user says "render sprite", "render model", "run the render pipeline",
"test the pipeline", "/render-sprite", or asks to render a specific model
"test the pipeline", "/sprite-gen", or asks to render a specific model
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
---
@@ -14,7 +14,7 @@ description: >
Run the render script with the model name:
```bash
.claude/skills/render-sprite/scripts/render.sh <model_name>
.claude/skills/sprite-gen/scripts/render.sh <model_name>
```
### Available Models
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
1. Create model scene at `renderer/models/<name>.tscn`
2. Apply texture from `renderer/textures/` via StandardMaterial3D
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
@@ -1,8 +1,8 @@
---
name: start-workshop
name: workshop-start
description: >
Start a multi-agent design workshop from a workshop brief. Use when the user says
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
Parses the workshop brief to extract participants, questions, and round format.
Creates a team, tasks, and spawns agents as teammates via the Task tool.
---
+1 -1
View File
@@ -73,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
Show a summary table of branches ahead of main. For each branch, indicate:
- `[PR]` if it has an open pull request — warn that it should go through
normal review channels (use `/review-pr` instead)
normal review channels (use `/pr-review` instead)
- Commit count and summary
Use `AskUserQuestion` to let the user pick which branches to merge.
+1
View File
@@ -1,5 +1,6 @@
# Build and cache
.cache/
.tmp/
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
+16
View File
@@ -6,6 +6,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.11] — 2026-02-19
### Added
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
- `sed -n` blanket permission in shared settings
### Changed
- All skills renamed to domain-action convention (e.g. `commit``git-commit`, `review-pr``pr-review`, `gen-audio``audio-gen`, `render-sprite``sprite-gen`) — 12 renames total
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
## [v0.1.10] — 2026-02-19
### Added
@@ -17,12 +28,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- 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
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
### 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
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
### 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`
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.
+25
View File
@@ -40,6 +40,17 @@ var dialogue_active: bool = false
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
# Cursor shape changes still fire when false (D-056 option a).
# v0.1 assumption: always true — both playable characters (detective and smuggler)
# have neural inserts. Future characters without inserts would receive false from
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
var insert_active: bool = true
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
# Null in v0.1 (server does not yet send this field; protocol change required).
var rng_seed: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -132,6 +143,20 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
room_id = null
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
# Server may send false for characters without an insert in future sprints.
if snapshot.has("insert_active") and snapshot.insert_active is bool:
insert_active = snapshot.insert_active
else:
insert_active = true
# #507: rng_seed — server sends current RNG seed for replay determinism.
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
if snapshot.has("rng_seed"):
rng_seed = snapshot.rng_seed
else:
rng_seed = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+4 -4
View File
@@ -179,7 +179,7 @@ func send_input(player_input: Dictionary) -> Error:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
@@ -192,9 +192,9 @@ func send_input(player_input: Dictionary) -> Error:
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
# _action_enum_to_wire already emits push_warning for invalid actions
# action_enum_to_wire already emits push_warning for invalid actions
return ERR_INVALID_PARAMETER
# Use the server's current tick so drain_for_tick processes this input immediately.
# The client-side timestamp_msec is only useful for ordering within a frame.
@@ -251,7 +251,7 @@ func drain_outbound() -> Array[Dictionary]:
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func _action_enum_to_wire(action: int) -> String:
static func action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
+25
View File
@@ -22,6 +22,7 @@ var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same t
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
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -81,6 +82,16 @@ func _process(_delta: float) -> void:
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
var insert_state := GameState.insert_active
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
cursor_renderer.set_insert_active(insert_state)
if interaction_list and interaction_list.has_method("set_insert_active"):
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
@@ -135,6 +146,8 @@ func _process(_delta: float) -> void:
camera.reset_smoothing()
# Send queued input to simulation
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
@@ -164,6 +177,18 @@ func _process(_delta: float) -> void:
"verb": null,
}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
# then clears the accumulator for the next tick.
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
bug_report_dialog.record_tick(
GameState.current_tick,
JSON.stringify(GameState.current_snapshot),
_pending_record_inputs
)
_pending_record_inputs.clear()
# Consume-once per tick: show monologue text, then clear.
@@ -11,6 +11,9 @@ enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
var current_state: State = State.DEFAULT
var hovered_entity_id: int = -1
var weapon_mode_active: bool = false
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
# Cursor shape transitions still fire — the character's body still orients to targets.
var insert_active: bool = true
signal state_changed(new_state: State)
signal hovered_entity_changed(entity_id: int)
@@ -305,8 +308,15 @@ func get_z_layer() -> int:
func should_show_interactions() -> bool:
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
if not insert_active:
return false
return not weapon_mode_active or _shift_held
func set_insert_active(active: bool) -> void:
insert_active = active
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles
+1 -1
View File
@@ -518,7 +518,7 @@ func test_bug_report_not_double_activatable() -> void:
func test_bug_report_action_not_on_wire() -> void:
# BUG_REPORT is client-only — must not produce a wire-format action name.
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.BUG_REPORT)
assert_that(wire_name).override_failure_message(
"BUG_REPORT must not produce a wire action name (client-only)"
).is_equal("")
+361
View File
@@ -0,0 +1,361 @@
## #507: WRONG button ring buffer tests.
## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd.
## Covers ring buffer capacity, circular overwrite, JSONL format compliance,
## replay compatibility, seed file, and regression against MVP behavior.
##
## MVP baseline (must still pass): snapshot.json, render.txt, description.txt.
## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt.
##
## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary)
## Sprint 11 client.md §Notes #507
class_name TestBugReportRingBuffer
extends GdUnitTestSuite
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
# Expected ring buffer capacity per spec.
const EXPECTED_CAPACITY := 60
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.current_tick = 0
GameState.room_id = null
GameState.player_stance = "Walk"
GameState.player_facing = "South"
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.rng_seed = null
# -- Helpers -------------------------------------------------------------------
func _make_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
auto_free(dialog)
add_child(dialog)
return dialog
## Create a minimal valid PlayerInput dict matching the replay.rs wire format.
## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict.
func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
return {"tick": tick, "action": action}
## Make a minimal snapshot JSON string for snapshot buffer testing.
func _make_snapshot_json(tick: int) -> String:
return JSON.stringify({
"tick": tick,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
})
## Check a string is valid JSON array — mirrors replay.rs line expectation.
func _is_valid_json_array(line: String) -> bool:
if line.is_empty():
return false
var result = JSON.parse_string(line)
return result != null and result is Array
# -- Ring buffer capacity ------------------------------------------------------
func test_buffer_capacity_is_60() -> void:
# Spec: "Maintain a 60-entry circular buffer"
var dialog = _make_dialog()
if not dialog.has_method("_get_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_buffer_capacity()).override_failure_message(
"Ring buffer capacity must be 60 per spec"
).is_equal(EXPECTED_CAPACITY)
func test_snapshot_buffer_capacity_is_60() -> void:
# Parallel snapshot buffer must match input buffer capacity.
var dialog = _make_dialog()
if not dialog.has_method("_get_snapshot_buffer_capacity"):
push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507")
return
assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY)
# -- Pre-allocation -----------------------------------------------------------
func test_input_buffer_preallocated_at_ready() -> void:
# Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick."
var dialog = _make_dialog()
if not dialog.has_method("_get_input_buffer"):
push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507")
return
var buf = dialog._get_input_buffer()
assert_that(buf is Array).override_failure_message(
"Input buffer must be an Array"
).is_true()
assert_that(buf.size()).override_failure_message(
"Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY
).is_equal(EXPECTED_CAPACITY)
# -- Push and fill behavior ---------------------------------------------------
func test_push_fills_input_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs"):
push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507")
return
if not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"10 pushed ticks should produce filled count of 10"
).is_equal(10)
func test_push_fills_snapshot_buffer() -> void:
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot"):
push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507")
return
if not dialog.has_method("_get_filled_snapshot_count"):
push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507")
return
for i in range(10):
dialog._push_tick_snapshot(_make_snapshot_json(i))
assert_that(dialog._get_filled_snapshot_count()).is_equal(10)
# -- Circular overwrite -------------------------------------------------------
func test_circular_overwrite_evicts_oldest() -> void:
# After 61 pushes, filled count must be 60 (oldest evicted).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)"
).is_equal(EXPECTED_CAPACITY)
func test_circular_overwrite_keeps_newest_inputs() -> void:
# After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507")
return
# Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present.
for i in range(EXPECTED_CAPACITY + 1):
dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")])
var jsonl: String = dialog._format_inputs_jsonl()
# Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten.
# We check that the output has exactly 60 lines.
var lines: PackedStringArray = jsonl.split("\n", false)
# Filter out blank lines (PackedStringArray has no filter — convert to Array first)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"After 61 pushes, JSONL output must have exactly 60 non-blank lines"
).is_equal(EXPECTED_CAPACITY)
func test_exact_60_pushes_no_eviction() -> void:
# After exactly 60 pushes, all 60 are present.
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
return
for i in range(EXPECTED_CAPACITY):
dialog._push_tick_inputs(i, [_make_input(i)])
assert_that(dialog._get_filled_input_count()).override_failure_message(
"After exactly 60 pushes, all 60 entries should be present"
).is_equal(EXPECTED_CAPACITY)
# -- JSONL format compliance (replay.rs contract) ----------------------------
func test_inputs_jsonl_each_line_is_json_array() -> void:
# replay.rs: "Each line is a JSON array of PlayerInput for one tick."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_inputs(i, [_make_input(i)])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
for line in non_blank:
assert_that(_is_valid_json_array(line)).override_failure_message(
"Each JSONL line must be a valid JSON array, got: %s" % line
).is_true()
func test_inputs_jsonl_idle_tick_is_empty_array() -> void:
# replay.rs: "Empty array = idle tick (no input sent)."
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, []) # Idle tick: no inputs
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).override_failure_message(
"One idle tick should produce exactly 1 JSONL line"
).is_equal(1)
assert_that(non_blank[0].strip_edges()).override_failure_message(
"Idle tick line must be an empty JSON array '[]'"
).is_equal("[]")
func test_inputs_jsonl_has_tick_field() -> void:
# replay.rs PlayerInput expects {"tick": N, "action": "..."}
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")])
var jsonl: String = dialog._format_inputs_jsonl()
# Parse the first line and verify structure
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_greater(0)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size() > 0).is_true()
var first_input: Dictionary = arr[0]
assert_that(first_input.has("tick")).override_failure_message(
"Each PlayerInput in JSONL must have 'tick' field"
).is_true()
assert_that(first_input.has("action")).override_failure_message(
"Each PlayerInput in JSONL must have 'action' field"
).is_true()
func test_inputs_jsonl_multiple_actions_per_tick() -> void:
# replay.rs supports multiple PlayerInput per tick (one array entry per action).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
return
dialog._push_tick_inputs(0, [
_make_input(0, "MoveNorth"),
_make_input(0, "Pause"),
])
var jsonl: String = dialog._format_inputs_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(1)
var arr = JSON.parse_string(non_blank[0])
assert_that(arr is Array).is_true()
assert_that(arr.size()).override_failure_message(
"Tick with 2 actions must have 2 entries in the JSONL array"
).is_equal(2)
func test_snapshots_jsonl_each_line_is_valid_json() -> void:
# Each snapshot line should be valid JSON (parsed snapshot).
var dialog = _make_dialog()
if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"):
push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507")
return
for i in range(3):
dialog._push_tick_snapshot(_make_snapshot_json(i))
var jsonl: String = dialog._format_snapshots_jsonl()
var lines: PackedStringArray = jsonl.split("\n", false)
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
assert_that(non_blank.size()).is_equal(3)
for line in non_blank:
var parsed = JSON.parse_string(line)
assert_that(parsed != null).override_failure_message(
"Each snapshot line must be valid JSON, got: %s" % line
).is_true()
assert_that(parsed is Dictionary).override_failure_message(
"Each snapshot line must parse to a Dictionary"
).is_true()
# -- Seed file ----------------------------------------------------------------
func test_seed_written_on_capture() -> void:
# Spec: "Include the server's current RNG seed... Output as seed.txt"
# We can't easily verify file output in headless mode, but we verify
# the dialog has the method to produce the seed value.
var dialog = _make_dialog()
if not dialog.has_method("_get_current_seed"):
push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507")
return
# Should return some Variant (int or String) without crashing
var seed_val = dialog._get_current_seed()
assert_that(seed_val != null).override_failure_message(
"_get_current_seed() must not return null (use 0 or 'unavailable' if seed unavailable)"
).is_true()
# -- Room metadata regression -------------------------------------------------
func test_description_txt_still_contains_room_id() -> void:
# Regression: MVP description.txt must still include room metadata.
GameState.current_tick = 5
GameState.room_id = "warehouse_01"
GameState.player_stance = "Walk"
GameState.player_facing = "North"
GameState.player_position = Vector2(10.0, 10.0)
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
# Call the render text method to verify room shows up in render output
# (description.txt itself writes via FileAccess, difficult in headless — test the render path)
if dialog.has_method("_render_snapshot_text"):
# The text render is for render.txt, but description.txt room comes from GameState.room_id
# We indirectly verify by checking that the game state has the right fields.
assert_that(GameState.room_id).is_equal("warehouse_01")
GameState.room_id = null
# -- MVP regression -----------------------------------------------------------
func test_render_snapshot_text_still_works() -> void:
# Regression: MVP _render_snapshot_text() must still work after upgrade.
GameState.current_tick = 10
GameState.player_position = Vector2(5.0, 5.0)
GameState.player_facing = "East"
GameState.player_stance = "Careful"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog = _make_dialog()
if not dialog.has_method("_render_snapshot_text"):
push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk")
return
var text: String = dialog._render_snapshot_text()
assert_that(text.length() > 0).is_true()
assert_that(text.contains("t10")).is_true()
func test_dialog_is_active_api_unchanged() -> void:
# Regression: public API from MVP must be unchanged.
var dialog = _make_dialog()
assert_that(dialog.has_method("start_capture")).override_failure_message(
"start_capture() must still be present (MVP regression)"
).is_true()
assert_that(dialog.has_method("is_active")).override_failure_message(
"is_active() must still be present (MVP regression)"
).is_true()
+40
View File
@@ -305,6 +305,46 @@ func test_cursor_changes_require_los() -> void:
cursor.queue_free()
# -- OQ-07: Insert-off behavior (D-056 amendment, #522) ----------------------
# Option (a): cursor shape still changes, verb labels suppressed.
func test_insert_off_cursor_still_changes_shape() -> void:
# OQ-07 option (a): cursor state machine still fires when insert is off.
# The character's body orients toward targets even without insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("set_hover_target"):
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_insert_off_suppresses_interactions() -> void:
# OQ-07 option (a): with insert off, should_show_interactions() returns false.
# Verb labels (z-layer 6) are suppressed — no actionable insert data.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_insert_on_restores_interaction_display() -> void:
# Re-enabling insert allows interactions to show again.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
+2 -2
View File
@@ -129,13 +129,13 @@ func test_gauntlet_mode_transitions_off() -> void:
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)
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)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
+400
View File
@@ -0,0 +1,400 @@
## #522: OQ-07 resolution — no-insert interaction behavior.
## Tests that when insert_active == false, interaction labels disappear
## (diegetic test from D-056 and D-057), and that cursor and interaction
## list agree on insert state.
##
## D-056: "Interaction labels render on z-layer 6 (insert overlay).
## If the insert is off, labels disappear."
## D-057: "Labels render on z-layer 6. If insert is off, labels disappear."
##
## OQ-07 resolution (expected option a): cursor shape still changes
## (character physically orients), but verb labels are suppressed.
##
## Spec refs: D-056, D-057, D-048, D-049
## Ticket #522
class_name TestInsertOffBehavior
extends GdUnitTestSuite
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
GameState.insert_active = true
# -- Helpers -------------------------------------------------------------------
func _make_cursor() -> Node:
for path in ["res://ui/cursor_state_machine.tscn", "res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
return null
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
push_warning("TestInsertOffBehavior: cursor scene not found — test skipped (awaiting #522)")
return cursor
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
add_child(node)
return node
return null
func _make_list_or_skip() -> Node:
var list = _make_interaction_list()
if list == null:
push_warning("TestInsertOffBehavior: interaction list scene not found — test skipped (awaiting #522)")
return list
func _set_test_interactions() -> void:
GameState.nearby_interactions = [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
func _clear_test_interactions() -> void:
GameState.nearby_interactions = []
# -- Interaction list: diegetic test (D-056, D-057) ---------------------------
func test_insert_off_hides_interaction_list() -> void:
# D-056/D-057 diegetic test: "If the insert is off, labels disappear."
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"D-056/D-057 diegetic test: interaction list must be hidden when insert is off"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).override_failure_message(
"Interaction list must show 0 verbs when insert is off"
).is_equal(0)
list.queue_free()
_clear_test_interactions()
func test_insert_on_shows_interaction_list() -> void:
# When insert is on (normal state), verbs should be visible.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if list.has_method("set_insert_active"):
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Interaction list must be visible when insert is on and verbs present"
).is_true()
list.queue_free()
_clear_test_interactions()
func test_insert_reenable_restores_list() -> void:
# Toggling insert off then on restores verb list visibility.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
if not list.has_method("set_insert_active"):
list.queue_free()
_clear_test_interactions()
return
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
list.set_insert_active(true)
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Re-enabling insert must restore list visibility"
).is_true()
list.queue_free()
_clear_test_interactions()
# -- Cursor: insert-off behavior (OQ-07 resolution option a) ------------------
func test_insert_off_suppresses_should_show_interactions() -> void:
# OQ-07 option (a): labels are suppressed when insert is off.
# Regardless of cursor visual state, should_show_interactions() must return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return false when insert is off"
).is_false()
cursor.queue_free()
func test_insert_on_restores_should_show_interactions() -> void:
# After re-enabling insert, interactions should show again (not in weapon mode).
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_insert_active(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"should_show_interactions() must return true after re-enabling insert"
).is_true()
cursor.queue_free()
func test_insert_off_at_startup_no_corruption() -> void:
# Insert can be off from the start — no state machine corruption.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("get_state"):
cursor.queue_free()
return
cursor.set_insert_active(false)
# Cursor must be in a valid state (not null/error)
var state_str := str(cursor.get_state())
assert_that(["Default", "EntityHover", "ObjectHover", "WeaponAim"].has(state_str)).override_failure_message(
"Cursor must be in a valid state after insert-off at startup, got: %s" % state_str
).is_true()
cursor.queue_free()
# -- OQ-07 option (a): cursor shape still changes, labels suppressed ----------
func test_insert_off_option_a_cursor_still_transitions() -> void:
# Option (a): cursor shape changes even when insert off
# (character physically orients to target, just no labels).
# If implementation chose option (b) instead, this test would fail — that's informative.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_hover_target"):
push_warning("TestInsertOffBehavior: set_insert_active or set_hover_target not found — awaiting #522")
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": true})
# Option (a): cursor SHOULD be in EntityHover despite insert being off
# Option (b): cursor would stay in Default
# Test documents expected behavior — fail message explains which option is active
if cursor.has_method("get_state"):
var state := str(cursor.get_state())
# If this assertion fails, option (b) was implemented (cursor full-suppressed).
# Neither (a) nor (b) is wrong — this test documents which was chosen.
# Consult decisions/perception.md for the OQ-07 amendment.
assert_that(state).override_failure_message(
"OQ-07 option (a): cursor should still transition to EntityHover when insert is off. "
+ "If this fails, option (b) was implemented (full suppression) — update this test to assert 'Default' instead."
).is_equal("EntityHover")
cursor.queue_free()
# -- Edge cases ---------------------------------------------------------------
func test_insert_off_plus_weapon_mode_stays_suppressed() -> void:
# Insert-off AND weapon mode: should_show_interactions() must still return false.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_weapon_mode"):
cursor.queue_free()
return
cursor.set_insert_active(false)
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off + weapon mode must keep should_show_interactions() false"
).is_false()
cursor.queue_free()
func test_insert_off_shift_override_still_suppressed() -> void:
# OQ-07: Insert-off trumps Shift override.
# Shift restores interactions in weapon mode, but insert-off is a harder constraint.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_shift_held"):
cursor.queue_free()
return
cursor.set_insert_active(false)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Insert-off must suppress interactions even with Shift held (insert-off > shift override)"
).is_false()
cursor.queue_free()
func test_insert_off_rapid_toggle_no_corruption() -> void:
# Rapid on/off toggle must not leave either system in inconsistent state.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if not cursor.has_method("set_insert_active"):
cursor.queue_free()
return
for _i in range(10):
cursor.set_insert_active(false)
cursor.set_insert_active(true)
# After 10 toggles, insert should be on
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"After rapid toggle ending on 'on', interactions must be visible"
).is_true()
cursor.queue_free()
# -- Cross-system consistency (cursor + list agree) ---------------------------
func test_cursor_and_list_agree_on_insert_off() -> void:
# Both cursor and interaction list must be suppressed when insert is off.
# This tests the integration contract — both must read from the same source of truth.
var cursor = _make_cursor_or_skip()
var list = _make_list_or_skip()
if cursor == null or list == null:
if cursor != null: cursor.queue_free()
if list != null: list.queue_free()
return
_set_test_interactions()
if cursor.has_method("set_insert_active") and list.has_method("set_insert_active"):
cursor.set_insert_active(false)
list.set_insert_active(false)
if list.has_method("update_from_state"):
list.update_from_state()
var cursor_suppressed := true
if cursor.has_method("should_show_interactions"):
cursor_suppressed = not cursor.should_show_interactions()
var list_suppressed := true
if list.has_method("is_showing"):
list_suppressed = not list.is_showing()
elif list.has_method("get_visible_verb_count"):
list_suppressed = list.get_visible_verb_count() == 0
assert_that(cursor_suppressed).override_failure_message(
"Cursor: should_show_interactions() must return false when insert is off"
).is_true()
assert_that(list_suppressed).override_failure_message(
"Interaction list: must be hidden when insert is off"
).is_true()
cursor.queue_free()
list.queue_free()
_clear_test_interactions()
func test_game_state_insert_active_field_exists() -> void:
# GameState must have an insert_active field after #522 implementation.
assert_that(GameState.get("insert_active") != null or "insert_active" in GameState).override_failure_message(
"GameState must have insert_active field after #522 — awaiting implementation"
).is_true()
func test_game_state_insert_active_default_true() -> void:
# Default state: insert is on (player starts with a functioning neural insert).
if not ("insert_active" in GameState):
push_warning("TestInsertOffBehavior: GameState.insert_active not found — awaiting #522")
return
assert_that(GameState.insert_active).override_failure_message(
"GameState.insert_active should default to true (insert is normally on)"
).is_true()
# -- D-049: z-layer verification (labels on layer 6) -------------------------
func test_interaction_list_on_insert_layer() -> void:
# D-056/D-057: "Labels render on z-layer 6 (insert overlay)"
# This is already tested in test_interaction_list.gd but we verify here
# that the z-layer is CANVAS_INSERT (the insert overlay layer).
var list = _make_list_or_skip()
if list == null:
return
if list.has_method("get_z_layer"):
assert_that(list.get_z_layer()).override_failure_message(
"Interaction list labels must render on CANVAS_INSERT (z-layer 6 per D-049)"
).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -- Regression: existing behavior unchanged ----------------------------------
func test_sprint_suppression_still_works_with_insert_on() -> void:
# D-055 regression: Sprint suppresses interaction list regardless of insert state.
var list = _make_list_or_skip()
if list == null:
return
_set_test_interactions()
# Ensure insert is on (should not affect sprint suppression)
if list.has_method("set_insert_active"):
list.set_insert_active(true)
GameState.player_stance = "Sprint"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).override_failure_message(
"Sprint must suppress interaction list even when insert is on (D-055 regression)"
).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
_clear_test_interactions()
GameState.player_stance = "Walk"
func test_weapon_mode_suppression_still_works_with_insert_on() -> void:
# D-056 regression: weapon mode suppresses interactions when insert is on.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_insert_active"):
cursor.set_insert_active(true) # Insert is on
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
if cursor.has_method("should_show_interactions"):
assert_that(cursor.should_show_interactions()).override_failure_message(
"Weapon mode must suppress interactions when insert is on (D-056 regression)"
).is_false()
cursor.queue_free()
+3 -3
View File
@@ -268,12 +268,12 @@ func test_insert_off_hides_interaction_list() -> void:
func test_stance_up_wire_mapping() -> void:
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
assert_that(wire).is_equal("ToggleStanceUp")
func test_stance_down_wire_mapping() -> void:
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
assert_that(wire).is_equal("ToggleStanceDown")
@@ -295,7 +295,7 @@ func test_all_movement_actions_have_wire_mapping() -> void:
InputMapper.Action.TOGGLE_STANCE_DOWN,
]
for action in actions_with_mapping:
var wire = SimBridge._action_enum_to_wire(action)
var wire = SimBridge.action_enum_to_wire(action)
assert_that(wire.length()).is_greater(0)
+6 -6
View File
@@ -163,23 +163,23 @@ func test_action_enum_to_wire_all_directions_clockwise() -> void:
]
for pair in expected:
var wire_name := SimBridge._action_enum_to_wire(pair[0])
var wire_name := SimBridge.action_enum_to_wire(pair[0])
assert_that(wire_name).is_equal(pair[1])
func test_action_enum_to_wire_non_movement() -> void:
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
func test_action_enum_to_wire_open_menu_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.OPEN_MENU)
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.OPEN_MENU)
assert_that(wire_name).is_equal("")
func test_action_enum_to_wire_unknown_returns_empty() -> void:
var wire_name := SimBridge._action_enum_to_wire(9999)
var wire_name := SimBridge.action_enum_to_wire(9999)
assert_that(wire_name).is_equal("")
+222 -5
View File
@@ -1,8 +1,23 @@
extends Control
## #495: WRONG button (F12) MVP — bug report capture dialog.
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
## Upgrade of the Sprint 9 MVP (#495).
##
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
## - snapshot.json — single-tick point-in-time (MVP compat)
## - render.txt — client-side text render
## - description.txt — tester notes + room/tick/seed metadata
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
##
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
signal capture_completed
signal capture_cancelled
@@ -16,14 +31,172 @@ const BOX_WIDTH := 500
const BOX_HEIGHT := 120
const PADDING := 16
# #507: Ring buffer capacity — 60 ticks of history
const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
# Separate heads and counts so each buffer can be tested independently.
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
# debug tool that is always active during Gauntlet play.
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
var _snapshot_head: int = 0
var _snapshot_count: int = 0
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
# Pre-allocate ring buffers — resize then fill sentinels.
# The ring array itself never grows after _ready. Each write replaces the GDScript
# reference in an existing slot (not a new allocation of the ring), though the input
# Array stored per slot is a fresh ref each tick.
_input_ring.resize(RING_SIZE)
_snapshot_ring.resize(RING_SIZE)
for i in range(RING_SIZE):
_input_ring[i] = []
_snapshot_ring[i] = ""
# ---------------------------------------------------------------------------
# Public API for main.gd: record one tick's data
# ---------------------------------------------------------------------------
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
## - tick: current server tick number
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
## These are in raw InputMapper format and will be converted to replay format.
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
# Convert mapper inputs to replay-compatible format, then push both buffers.
var replay_inputs: Array = []
for inp in mapper_inputs:
var ri := _to_replay_format(inp, tick)
if not ri.is_empty():
replay_inputs.append(ri)
_push_tick_inputs(tick, replay_inputs)
_push_tick_snapshot(snapshot_json)
# ---------------------------------------------------------------------------
# Internal ring buffer operations (also exposed for tests)
# ---------------------------------------------------------------------------
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
## Overwrites oldest entry when buffer is full (circular eviction).
@warning_ignore("unused_parameter")
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
_input_ring[_input_head] = inputs
_input_head = (_input_head + 1) % RING_SIZE
if _input_count < RING_SIZE:
_input_count += 1
## Push a JSON-serialized ObserverSnapshot string for one tick.
func _push_tick_snapshot(snapshot_json: String) -> void:
_snapshot_ring[_snapshot_head] = snapshot_json
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
if _snapshot_count < RING_SIZE:
_snapshot_count += 1
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
## Returns a String with one JSON array per line, oldest to newest.
## Each line: Array of {tick, action} replay-format PlayerInput objects.
func _format_inputs_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
for i in range(_input_count):
var idx := (start + i) % RING_SIZE
lines.append(JSON.stringify(_input_ring[idx]))
return "\n".join(lines)
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
## Returns a String with one JSON string per line, oldest to newest.
func _format_snapshots_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
for i in range(_snapshot_count):
var idx := (start + i) % RING_SIZE
lines.append(_snapshot_ring[idx])
return "\n".join(lines)
## Return the RNG seed for seed.txt. Never returns null.
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
## loses precision above 2^53 via float intermediary. When the server field
## lands, consider string-encoding the seed to preserve all 64 bits.
func _get_current_seed() -> Variant:
if GameState.rng_seed != null:
return GameState.rng_seed
return "unavailable"
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
## Replay format: {"tick": N, "action": "MoveNorth"} or
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
var action_enum: int = input.get("action", -1)
var wire: String = SimBridge.action_enum_to_wire(action_enum)
if wire.is_empty():
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
var result := {"tick": tick}
var action_data: Variant = input.get("action_data")
match wire:
"Interact":
# Rust PlayerAction::Interact { target_entity_id, verb }
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
"SetFacing":
# Rust PlayerAction::SetFacing(String) — wrap direction string
var facing := ""
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
result["action"] = {"SetFacing": facing}
_:
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
result["action"] = wire
return result
# ---------------------------------------------------------------------------
# Test-accessible accessors (ring buffer introspection)
# ---------------------------------------------------------------------------
func _get_buffer_capacity() -> int:
return RING_SIZE
func _get_snapshot_buffer_capacity() -> int:
return RING_SIZE
func _get_input_buffer() -> Array:
return _input_ring
func _get_filled_input_count() -> int:
return _input_count
func _get_filled_snapshot_count() -> int:
return _snapshot_count
# ---------------------------------------------------------------------------
# UI / capture flow
# ---------------------------------------------------------------------------
func start_capture() -> void:
if _active:
@@ -94,7 +267,7 @@ func _save_report(description: String) -> void:
var files_saved := 0
# 1. snapshot.json — full current snapshot as JSON
# 1. snapshot.json — single-tick point-in-time (MVP compat, #495)
var snapshot_path := base_path + "/snapshot.json"
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
if snapshot_file:
@@ -115,6 +288,7 @@ func _save_report(description: String) -> void:
push_error("BugReport: failed to write %s" % render_path)
# 3. description.txt — tester description + metadata
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
var desc_path := base_path + "/description.txt"
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
if desc_file:
@@ -125,12 +299,53 @@ func _save_report(description: String) -> void:
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
desc_file.store_string("RingBufferTicks: %d\n" % _input_count)
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
# One JSON array per line. Empty array = idle tick.
# Compatible with tooling/test-client --replay (replay.rs).
var inputs_path := base_path + "/inputs.jsonl"
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
if inputs_file:
inputs_file.store_string(_format_inputs_jsonl())
inputs_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % inputs_path)
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
var snaps_path := base_path + "/snapshots.jsonl"
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
if snaps_file:
snaps_file.store_string(_format_snapshots_jsonl())
snaps_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snaps_path)
# 6. seed.txt — RNG seed for deterministic replay.
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
# If absent: includes a note on the required protocol change.
var seed_path := base_path + "/seed.txt"
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
if seed_file:
var seed_val: Variant = _get_current_seed()
seed_file.store_string(str(seed_val) + "\n")
if seed_val == "unavailable":
seed_file.store_string(
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
)
seed_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % seed_path)
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
## Simplified client-side text render of the current snapshot.
@@ -200,7 +415,9 @@ func _draw() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
# -- Public API ---------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func is_active() -> bool:
return _active
+15
View File
@@ -13,6 +13,8 @@ extends PanelContainer
var _is_showing: bool = false
var _active_tween: Tween = null
var _current_target_id: int = -1
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
var _insert_active: bool = true
const FADE_IN: float = 0.15
const FADE_OUT: float = 0.15
@@ -23,6 +25,11 @@ func _ready() -> void:
_is_showing = false
func _process(_delta: float) -> void:
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
if not _insert_active:
if _is_showing:
_hide_prompt()
return
var interactions: Array = GameState.nearby_interactions
if interactions.size() > 0:
_show_prompt(interactions[0])
@@ -69,6 +76,14 @@ func _hide_prompt() -> void:
func get_interaction_target() -> int:
return _current_target_id
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
## Cursor shape changes still fire on cursor_renderer.gd.
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _is_showing:
_hide_prompt()
## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection).
func get_selected_verb() -> String:
var interactions: Array = GameState.nearby_interactions
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Batch audio generation from a manifest file. Whitelistable command.
# Usage: audio-batch manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
exec python3 "$(dirname "$0")/audio_batch.py" "$@"
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Batch audio generation from a manifest file.
Processes multiple assets sequentially: SAO generation or harmonic synthesis,
followed by post-processing (trim, normalize, convert to OGG).
Usage:
python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
"""
import json
import math
import os
import struct
import subprocess
import sys
import wave
import numpy as np
def load_manifest(path):
with open(path) as f:
return json.load(f)
def resolve_paths(manifest, manifest_dir):
"""Resolve output_dir and gen_dir relative to the git root."""
# Find git root by walking up from manifest_dir
# Check for .git as file (worktree) or directory (regular repo)
git_root = manifest_dir
while git_root != "/":
if os.path.exists(os.path.join(git_root, ".git")):
break
git_root = os.path.dirname(git_root)
else:
git_root = manifest_dir
output_dir = os.path.join(git_root, manifest.get("output_dir", "client/assets/audio"))
gen_dir = os.path.join(git_root, manifest.get("gen_dir", "client/assets/audio/gen"))
os.makedirs(output_dir, exist_ok=True)
os.makedirs(gen_dir, exist_ok=True)
return output_dir, gen_dir, git_root
def get_default(manifest, asset, key):
"""Get a value from the asset, falling back to manifest defaults."""
defaults = manifest.get("defaults", {})
return asset.get(key, defaults.get(key))
def run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir):
"""Generate audio via Stable Audio Open + post-processing."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + ".wav")
ogg_path = os.path.join(output_dir, filename)
prompt = asset["prompt"]
duration = asset.get("duration", 10)
steps = get_default(manifest, asset, "steps") or 100
cfg = get_default(manifest, asset, "cfg") or 7
timeout = get_default(manifest, asset, "timeout") or 600
# Run audio-generate with --post
cmd = [
sys.executable, os.path.join(script_dir, "audio_connector.py"),
"generate", prompt,
"--duration", str(duration),
"--steps", str(steps),
"--cfg", str(cfg),
"--output", wav_path,
"--output-ogg", ogg_path,
"--timeout", str(timeout),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
if result.returncode != 0:
stderr = result.stderr.strip()
try:
err = json.loads(result.stdout)
return {"ok": False, "error": err.get("error", stderr)}
except (json.JSONDecodeError, ValueError):
return {"ok": False, "error": stderr or "generation failed"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": False, "error": f"Unexpected output: {result.stdout[:200]}"}
def synthesize_harmonic(params, wav_path):
"""Synthesize audio from harmonic parameters."""
sr = 44100
duration = params["duration"]
fundamental = params["fundamental"]
harmonics = params.get("harmonics", [])
attack_ms = params.get("attack_ms", 10)
sustain_ratio = params.get("sustain_ratio", 0.2)
decay = params.get("decay", "exponential")
n = int(sr * duration)
t = np.linspace(0, duration, n, endpoint=False)
# Fundamental
signal = np.sin(2 * np.pi * fundamental * t)
# Add harmonics
for h in harmonics:
freq = h["freq"]
db = h["db"]
amplitude = 10 ** (db / 20)
signal = signal + amplitude * np.sin(2 * np.pi * freq * t)
# Envelope: attack + sustain + decay
attack_s = attack_ms / 1000
attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n)
sustain_end = duration * sustain_ratio
if decay == "exponential":
# Decay rate: reach -60dB by end of duration
decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10
decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end)))
else:
# Linear decay
decay_env = np.where(t < sustain_end, 1.0,
1.0 - (t - sustain_end) / (duration - sustain_end))
envelope = attack_env * decay_env
signal = signal * envelope
# Normalize to peak
peak = np.max(np.abs(signal))
if peak > 0:
signal = signal / peak * 0.9
# Write WAV
int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16)
with wave.open(wav_path, "w") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(sr)
f.writeframes(int_samples.tobytes())
return wav_path
def run_synth(asset, manifest, gen_dir, output_dir, script_dir):
"""Synthesize audio from harmonic parameters + post-process."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + "_synth.wav")
ogg_path = os.path.join(output_dir, filename)
synth_params = asset.get("synth")
if not synth_params:
return {"ok": False, "error": "No synth parameters provided"}
synth_type = synth_params.get("type", "harmonic")
if synth_type != "harmonic":
return {"ok": False, "error": f"Unknown synth type: {synth_type}"}
try:
synthesize_harmonic(synth_params, wav_path)
except Exception as e:
return {"ok": False, "error": f"Synthesis failed: {e}"}
# Post-process: normalize + convert (skip trim for synth — no silence to trim)
post_script = os.path.join(script_dir, "audio_post.py")
lufs = get_default(manifest, asset, "lufs") or -16
quality = get_default(manifest, asset, "quality") or 6
# Normalize
norm_path = os.path.join(gen_dir, base_name + "_norm.wav")
cmd = [sys.executable, post_script, "normalize", wav_path, "--output", norm_path,
"--lufs", str(lufs)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Normalize failed: {result.stderr.strip()}"}
# Convert to OGG
cmd = [sys.executable, post_script, "convert", norm_path, "--output", ogg_path,
"--quality", str(quality)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Convert failed: {result.stderr.strip()}"}
# Clean up intermediate
try:
os.remove(norm_path)
except OSError:
pass
ogg_size = os.path.getsize(ogg_path)
return {
"ok": True,
"file": wav_path,
"ogg_file": ogg_path,
"ogg_size_bytes": ogg_size,
"synth_params": synth_params,
"post_processed": True,
}
def main():
if len(sys.argv) < 2:
print("Usage: audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]",
file=sys.stderr)
sys.exit(1)
manifest_path = sys.argv[1]
dry_run = "--dry-run" in sys.argv
skip_existing = "--skip-existing" in sys.argv
only_ids = None
for i, arg in enumerate(sys.argv):
if arg == "--only" and i + 1 < len(sys.argv):
only_ids = set(sys.argv[i + 1].split(","))
manifest = load_manifest(manifest_path)
manifest_dir = os.path.dirname(os.path.abspath(manifest_path))
output_dir, gen_dir, git_root = resolve_paths(manifest, manifest_dir)
script_dir = os.path.dirname(os.path.abspath(__file__))
assets = manifest.get("assets", [])
if only_ids:
assets = [a for a in assets if a["id"] in only_ids]
# Health check if any SAO assets
sao_assets = [a for a in assets if a.get("method") == "sao"]
if sao_assets and not dry_run:
print(f"Checking SAO API health...", file=sys.stderr)
health_cmd = [sys.executable, os.path.join(script_dir, "audio_connector.py"), "health"]
result = subprocess.run(health_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(json.dumps({"ok": False, "error": "SAO API health check failed",
"details": result.stdout.strip()}))
sys.exit(1)
print(f" SAO API is up.", file=sys.stderr)
total = len(assets)
results = []
success = 0
failed = 0
skipped = 0
print(f"Processing {total} assets from {os.path.basename(manifest_path)}...", file=sys.stderr)
if dry_run:
print(" (dry run — no generation will occur)", file=sys.stderr)
for i, asset in enumerate(assets, 1):
asset_id = asset["id"]
filename = asset["filename"]
method = asset.get("method", "sao")
print(f"\n[{i}/{total}] {asset_id}: {filename} ({method})", file=sys.stderr)
if skip_existing:
ogg_path = os.path.join(output_dir, filename)
if os.path.exists(ogg_path):
print(f" Skipping — already exists", file=sys.stderr)
results.append({"id": asset_id, "status": "skipped", "reason": "exists"})
skipped += 1
continue
if dry_run:
print(f" Would generate: {filename}", file=sys.stderr)
if method == "sao":
print(f" Prompt: {asset.get('prompt', '(none)')[:80]}...", file=sys.stderr)
elif method == "synth":
synth = asset.get("synth", {})
print(f" Synth: {synth.get('fundamental')}Hz, {synth.get('duration')}s",
file=sys.stderr)
results.append({"id": asset_id, "status": "dry_run"})
continue
if method == "sao":
result = run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir)
elif method == "synth":
result = run_synth(asset, manifest, gen_dir, output_dir, script_dir)
else:
result = {"ok": False, "error": f"Unknown method: {method}"}
result["id"] = asset_id
if result.get("ok"):
success += 1
result["status"] = "success"
print(f" OK → {result.get('ogg_file', filename)}", file=sys.stderr)
else:
failed += 1
result["status"] = "failed"
print(f" FAILED: {result.get('error', 'unknown')}", file=sys.stderr)
results.append(result)
# Summary
summary = {
"ok": failed == 0,
"total": total,
"success": success,
"failed": failed,
"skipped": skipped,
"results": results,
}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
+55 -5
View File
@@ -6,12 +6,13 @@ Talks to the Stable Audio Open Gradio app at tower-of-joy:11500.
Uses the async Gradio API pattern: POST to submit, SSE stream for results.
Usage:
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav]
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav] [--post]
python3 audio_connector.py health
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
@@ -56,7 +57,31 @@ def health():
}, indent=2))
sys.exit(1)
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600):
def post_process(wav_path, ogg_path=None, lufs=-16, quality=6, threshold=-50):
"""Run trim + normalize + convert on a WAV file via audio-post pipeline."""
script_dir = os.path.dirname(os.path.abspath(__file__))
post_script = os.path.join(script_dir, "audio_post.py")
if ogg_path is None:
ogg_path = os.path.splitext(wav_path)[0] + ".ogg"
cmd = [
sys.executable, post_script, "pipeline", wav_path,
"--output", ogg_path,
"--lufs", str(lufs),
"--quality", str(quality),
"--threshold", str(threshold),
]
print(f" Post-processing → {os.path.basename(ogg_path)}...", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Post-processing failed: {result.stderr.strip()}"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": True, "output": ogg_path}
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600,
post=False, output_ogg=None):
"""
Generate audio from a text prompt.
@@ -67,6 +92,8 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
cfg: Classifier-free guidance scale (default 7)
output: Output file path (default: auto-named in current directory)
timeout: Maximum wait time in seconds (default 600 = 10 minutes)
post: If True, run trim+normalize+convert after generation
output_ogg: OGG output path when post=True (default: same basename .ogg)
"""
base = get_base_url()
api_url = f"{base}/gradio_api/call/generate_audio"
@@ -216,7 +243,7 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
shutil.copyfileobj(resp, f)
file_size = os.path.getsize(output)
print(json.dumps({
result_json = {
"ok": True,
"file": output,
"size_bytes": file_size,
@@ -225,7 +252,20 @@ def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600
"cfg": cfg,
"prompt": prompt,
"generation_time_s": elapsed
}, indent=2))
}
if post:
post_result = post_process(output, ogg_path=output_ogg)
if not post_result.get("ok"):
result_json["post_processed"] = False
result_json["post_error"] = post_result.get("error", "unknown")
else:
result_json["post_processed"] = True
result_json["ogg_file"] = post_result.get("output", output_ogg)
ogg_size = os.path.getsize(result_json["ogg_file"])
result_json["ogg_size_bytes"] = ogg_size
print(json.dumps(result_json, indent=2))
except Exception as e:
print(json.dumps({
@@ -258,6 +298,8 @@ def main():
cfg = 7.0
output = None
timeout = 600
post = False
output_ogg = None
# Parse optional args
i = 3
@@ -277,11 +319,19 @@ def main():
elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv):
timeout = int(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--post":
post = True
i += 1
elif sys.argv[i] == "--output-ogg" and i + 1 < len(sys.argv):
output_ogg = sys.argv[i + 1]
post = True # --output-ogg implies --post
i += 2
else:
print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr)
sys.exit(1)
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output, timeout=timeout)
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output,
timeout=timeout, post=post, output_ogg=output_ogg)
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
+22 -1
View File
@@ -159,6 +159,27 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Inigo (insert-tech/organic split), Paula (cognitive architecture framing and trust model connection)
- **Dissent:** None
### D-075: Dialogue filtering — layered confidence gate on trust tier (OQ-18 resolution)
- **Date:** 2026-02-19
- **Decision:** KnowledgeConfidence ([D-041](architecture.md#d-041-knowledge-graph-data-model)) acts as a co-gate on TrustTier ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) Layer 3), not on AccessTier (Layer 1). Access tier and trust tier remain two separate filtering dimensions with different drivers:
1. **AccessTier (Layer 1):** Gated by `RelationshipState` only. Social position determines what *categories* of dialogue are available. No change from current implementation. Character archetype effects are emergent — the detective's investigation naturally creates PersonOfInterest relationships (Authority access), the smuggler's social arc naturally creates Known/Friendly relationships (Peer/Insider access). No archetype tag on the pipeline.
2. **TrustTier (Layer 3):** Gated by both `RelationshipState` AND `KnowledgeConfidence`:
- **Surface:** any relationship + any confidence — baseline, always available.
- **Real:** (Friendly or Known) + KnowsOf+ — requires both rapport and substantive knowledge.
- **Secret:** Friendly + KnowsDetails+ — requires both deep rapport and actionable knowledge.
3. **KnowledgeConfidence does NOT gate AccessTier.** Access is a social/positional concept ("who are you to me?"), not an information concept ("what do you know about me?"). A stranger can have Authority access (detective flashes badge) with zero knowledge. An insider can have Peer access before they know anything specific about the target.
- **Key design choice — no archetype dimension.** The dialogue pipeline does not add a character-archetype tag (detective/smuggler) as a filtering axis. Instead, archetype effects on dialogue emerge from: (a) different starting RelationshipStates driven by gameplay (detective institutions → Authority access, smuggler social network → Insider access), (b) different knowledge accumulation rates (detective's analytical lattice gains KnowsOf faster → Real trust earlier), (c) D-028 Layer 1 access tags on lines already encode "this line is for authority figures" vs "this line is for insiders." This is architecturally consistent with D-010 principle 3 (no baking player identity into the game loop).
- **Rationale:** Three reasons for layered-but-not-archetype:
1. *Separation of concerns.* Access (social position) and trust (relationship depth x knowledge depth) answer different questions. Collapsing them into one axis would require rewriting D-028's four-layer model and D-035's tag taxonomy — both confirmed and implemented.
2. *Minimal code change.* The only implementation change is adding a `KnowledgeConfidence` parameter to `relationship_to_trust()` in `server/src/simulation/dialogue.rs`. The caller already has access to the observer's KnowledgeGraph. No new components, no new tags, no content format changes.
3. *Emergent archetype distinction.* Hardcoding archetype tags creates a maintenance burden (new character = new tag = new content variant) and reduces the "two keyholes on the same world" experience. When the detective and smuggler experience different dialogue from the same NPC, it should be because they have different *relationships* and *knowledge*, not because a tag excluded them.
- **Implementation change to #305:** `relationship_to_trust()` gains a `confidence` parameter. Mapping: `(Friendly, KnowsDetails+) → Secret`, `(Friendly|Known, KnowsOf+) → Real`, `(_ , _) → Surface`. Caller in `process_talk_interaction` passes `observer_kg.confidence_of(&target_sid)` to the updated function.
- **Resolves:** OQ-18
- **Amends:** [D-041](architecture.md#d-041-knowledge-graph-data-model) (confirms confidence-to-trust mapping; supersedes the preliminary 1:1 sketch in D-041 "Key design choices" bullet 3 with the layered model above), [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) (Layer 3 trust now requires both relationship AND confidence)
- **Cross-reference:** [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers), [D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-062](#d-062-invisible-locked-dialogue-options) (confidence progression naturally unlocks new trust tiers, creating the "new options appearing" reward)
- **Raised by:** Tyre (technical analysis, architecture synthesis)
- **Dissent:** Pending review. Sprint briefing flags Gestalt and Nigel for archetype dimension input.
---
*15 decisions. Last updated: 2026-02-16*
*16 decisions. Last updated: 2026-02-19*
+10 -1
View File
@@ -212,6 +212,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Araminta (visual spec), Stig (UX rules + diegetic test), Ozzie (weapon suppression)
- **Dissent:** None.
- **OQ-07 resolution (2026-02-19, #522):** Insert-off behavior is **option (a): cursor shape still changes, verb labels suppressed.**
- Cursor state machine fires normally (entity hover → bracket shape, object hover → X-shape) — the character's body physically orients toward targets as a subconscious/spatial response.
- Insert does not process targets into actionable data: `should_show_interactions()` returns false when `insert_active == false`, and interaction labels (z-layer 6) are hidden via `set_insert_active(false)` on `InteractionList` and `InteractionPrompt`.
- `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`).
- Rationale: diegetically consistent — the body reacts to proximity; the insert reacts to commands.
### D-057: Entity interaction — vertical list, insert-styled
- **Date:** 2026-02-13
@@ -224,6 +229,10 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (vertical list structure + diegetic test), Araminta (insert aesthetic), Dudley (two-phase verb computation), Nigel (character-archetype verb sets). Lead resolved: Stig's structure, Araminta's styling.
- **Dissent:** Araminta argued for spoke radial (geometry transformation signals qualitative knowledge change — new spoke growing). Lead rejected: items moving under cursor when knowledge changes is a moving goalpost (bad UX while aiming at an option).
- **OQ-07 resolution (2026-02-19, #522):**
- When `insert_active == false`, the interaction list hides completely (`set_insert_active(false)``_hide()`).
- Cursor shape changes still occur per D-056 OQ-07 — list suppression is independent of cursor state.
- `GameState.insert_active` drives this at runtime, wired via `main.gd` on each snapshot.
### D-058: World menu — radial, 4 spokes
- **Date:** 2026-02-13
@@ -338,4 +347,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
---
*29 decisions. Last updated: 2026-02-16*
*29 decisions. Last updated: 2026-02-19 (OQ-07 resolved: D-056/D-057 amendment)*
+1 -1
View File
@@ -281,7 +281,7 @@ The sync runs automatically as part of `make setup` and via pre-commit hook. Mar
## Commit Conventions
See the `/commit` skill (`.claude/skills/commit/`) for full details. Summary:
See the `/git-commit` skill (`.claude/skills/git-commit/`) for full details. Summary:
- Conventional commits: `type(scope): summary`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `data`, `loc`
+4 -4
View File
@@ -14,10 +14,10 @@ Stable Audio Open primary. Prompt strategy: describe the SPACE, not the sound.
| ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint |
|----|----------|--------|-----|--------|----------|-------------|--------|
| AMB-001 | `amb_station_base.ogg` | planned | Ambient | SAO | 45-60s loop | Station hum, span gate vibration, ventilation. Low-freq foundation (60-120Hz), mid texture (200-800Hz). Always playing — acoustic foundation of being on a station. | S8+ |
| AMB-002 | `amb_workplace_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Cargo machinery, scanner pings, distant procedural voices (designations, not conversation). Rhythmic mechanical cadence. Terminal zone overlay. | S8+ |
| AMB-003 | `amb_bar_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Conversation murmur (300Hz-3kHz), softened glass sounds, faint Meridian music (barely melodic, distant radio). Social warmth. Murmur baked in — bar IS the crowd. Last Shift zone overlay. | S8+ |
| AMB-004 | `amb_corridor_layer.ogg` | planned | Ambient | SAO | 45-60s loop | Echoing footsteps, ventilation whistle with reverb tail, louder gate hum (closer to structure). Emptier mid-range, prominent low-end. Liminal emptiness. Corridor zone overlay. | S8+ |
| AMB-001 | `amb_station_base.ogg` | done | Ambient | SAO | 43.3s loop | Station hum, span gate vibration, ventilation. Low-freq foundation (60-120Hz), mid texture (200-800Hz). Always playing — acoustic foundation of being on a station. SAO 45s/150 steps/CFG 5, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-002 | `amb_workplace_layer.ogg` | done | Ambient | SAO | 42.9s loop | Cargo machinery, scanner pings, distant procedural voices (designations, not conversation). Rhythmic mechanical cadence. Terminal zone overlay. SAO 45s/150 steps/CFG 6, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-003 | `amb_bar_layer.ogg` | done | Ambient | SAO | 43.6s loop | Conversation murmur (300Hz-3kHz), softened glass sounds, faint Meridian music (barely melodic, distant radio). Social warmth. Murmur baked in — bar IS the crowd. Last Shift zone overlay. SAO 45s/150 steps/CFG 6, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
| AMB-004 | `amb_corridor_layer.ogg` | done | Ambient | SAO | 42.7s loop | Echoing footsteps, ventilation whistle with reverb tail, louder gate hum (closer to structure). Emptier mid-range, prominent low-end. Liminal emptiness. Corridor zone overlay. SAO 45s/150 steps/CFG 5, equal-power crossfade loop, LUFS -16 normalized. | S10 #327 |
## Detailed Entries
+2 -2
View File
@@ -13,8 +13,8 @@ Mixed methods depending on duration and character:
| ID | Filename | Status | Bus | Method | Duration | Prompt/Notes | Sprint |
|----|----------|--------|-----|--------|----------|-------------|--------|
| SFX-001 | `sfx_footstep_metal.ogg` | planned | Player Actions | hybrid | 0.3-0.5s | Single footstep on metal grating. Walk pace. Player character proprioceptive feedback. | S8+ |
| SFX-002 | `sfx_footstep_metal_run.ogg` | planned | Player Actions | hybrid | 0.2-0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). | S8+ |
| SFX-001 | `sfx_footstep_metal_walk.ogg` | done | Player Actions | SAO hybrid | 0.3s | Single footstep on metal grating. Walk pace. SAO 5s/100 steps/CFG 8, best transient trimmed at 2.42s, LUFS -16 normalized. | S10 #327 |
| SFX-002 | `sfx_footstep_metal_run.ogg` | done | Player Actions | SAO hybrid | 0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). SAO 5s/100 steps/CFG 8, best transient trimmed at 1.03s, LUFS -16 normalized. | S10 #327 |
## Detailed Entries
+3 -3
View File
@@ -5,7 +5,7 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
## Generation Approach
- **All UI sounds:** Generated via Stable Audio Open with sonic family prefix prompts, then trimmed/normalized/converted via `audio-post pipeline`.
- **Monologue chimes:** Re-generated in Sprint 9 (#453). Insert-tech prefix + Chimes template, 5s SAO generation trimmed to 0.8s.
- **Monologue chimes:** Replaced in Sprint 10 (#327) with manual synthesis. Insert-tech aesthetic: pure sine harmonics, mathematical envelope, no SAO. Previous S9 SAO versions were acknowledged placeholders per D-038 amendment.
## Assets
@@ -15,8 +15,8 @@ Interface sounds triggered by player interaction, insert systems, and cognitive
| UI-002 | `implant_open.ogg` | done | UI Sounds | SAO | 217ms | Rising tone when insert UI opens (radial menu, inventory, stance). Neural lattice powering up. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-003 | `fog_recognition.ogg` | done | UI Sounds | SAO | 400ms | Warm organic chime at onset of cognitive delay (D-060). Re-generated in S9 — original was silent. Organic prefix + Chimes template. | S7 #440, S9 #453 |
| UI-004 | `weapon_aim.ogg` | done | UI Sounds | SAO | 127ms | Harder click/lock for weapon aim state. Mechanical, deliberate. Trimmed + normalized in S9 #453. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template. Crystalline tone, neural lattice surfacing a thought. See design brief below. | S7 #440, S9 #453 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | SAO | 800ms | Re-generated in S9. Insert-tech prefix + Chimes template (sharper/brighter variant). CFG 8. See design brief below. | S7 #440, S9 #453 |
| UI-005 | `sfx_monologue_chime.ogg` | done | UI Sounds | manual synthesis | 800ms | Production-quality manual synthesis in S10. Insert-tech: 1200Hz fundamental + harmonics (2400Hz -12dB, 3600Hz -24dB, 6000Hz -30dB). 15ms attack, 0.2 sustain ratio, exp decay. Peak -15dB. No SAO — pure mathematical precision. | S7 #440, S9 #453, S10 #327 |
| UI-006 | `sfx_monologue_chime_urgent.ogg` | done | UI Sounds | manual synthesis | 800ms | Production-quality manual synthesis in S10. Insert-tech: 1220Hz fundamental (detuned +20Hz for tension) + stronger harmonics (2440Hz -6dB, 3660Hz -18dB, 5087Hz -24dB, inharmonic 5087Hz -28dB). 8ms attack, 0.3 sustain ratio. Peak -6.3dB. | S7 #440, S9 #453, S10 #327 |
| UI-007 | `sfx_weapon_aim_lock.ogg` | done | UI Sounds | SAO | 505ms | Sharp targeting lock-on confirmation for weapon aim. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
| UI-008 | `sfx_stance_change.ogg` | done | UI Sounds | SAO | 349ms | Soft mechanical mode-switch click for stance toggle. Downsampled to 44.1kHz + normalized in S9 #453. | S8 #440, S9 #453 |
+1 -1
View File
@@ -19,7 +19,7 @@ See [palette.md](palette.md) for art direction, "the angle", color relationships
## Pipeline
- **3D render pipeline:** Blender model → Godot Camera3D at -72.5deg → orthographic render → outline pass → resolution downscale
- **Render skill:** `/render-sprite` produces 12 PNGs (4 directions x 3 resolutions)
- **Render skill:** `/sprite-gen` produces 12 PNGs (4 directions x 3 resolutions)
- **Art direction workshop:** `docs/workshops/art-direction-mood-board/`
## Decision References
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
# Sprint 11: Combine — Client Tasks
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Branch:** `client`
**Agents:** Stig (UI/client dev), Tyre (arch review), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #507 | WRONG button full captures (input history ring buffer, snapshot history, replay seed) | #495 (done) |
| #522 | Resolve OQ-07: no-insert interaction behavior (diegetic test) | — |
Use `db/connectors/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot/Rust IPC, ObserverSnapshot as the only data crossing the boundary), D-030 (testability architecture — JSON output format for bug reports)
- `decisions/perception.md` — D-056 (cursor states — insert-styled geometric, diegetic test: labels render on z-layer 6, disappear if insert is off), D-057 (entity interaction — vertical list, diegetic test referenced), D-048 (neural insert overlay — visual design)
---
## Open Questions to Resolve Early
- **OQ-07 (#522): No-insert interaction behavior** — Resolve early (day 1-2) because the answer determines whether #507's WRONG button output includes cursor-state data in the capture, and whether the entity interaction system (#432) needs a code path for insert-off mode. Three candidate resolutions: (a) cursor reverts to default shape only, no verb labels; (b) cursor does not change at all; (c) non-insert fallback renders prompts via alternative channel. Resolve, document as a decision amendment to D-056 or D-057, then implement.
---
## Notes
**#507 — WRONG button full captures (input history ring buffer, snapshot history, replay seed)**
- Existing: The WRONG button MVP (#495, done Sprint 9) is in place as a `bug_report.gd` autoload. F12 pauses, captures a single `ObserverSnapshot` (JSON), runs the text renderer, saves tick/room/seed and a tester description to `tests/bug-reports/gauntlet-t{tick}-{timestamp}/`. The MVP is a single-tick point-in-time capture.
- Deliver: Upgrade the bug capture system to a rolling 60-tick history:
- **Input history ring buffer:** Maintain a 60-entry circular buffer of `PlayerInput` arrays in `bug_report.gd`. Every tick, push the current tick's input(s) onto the buffer (oldest entry evicted when full). On F12 capture, flush the last 60 ticks of inputs to `inputs.jsonl` (one JSON array per line — matches the replay format from `tooling/test-client/src/replay.rs`).
- **Snapshot history:** Maintain a parallel 60-entry circular buffer of `ObserverSnapshot` JSON strings. Flush to `snapshots.jsonl` on capture (one snapshot per line). Allows replay of the exact 60-tick window leading to the bug.
- **Replay seed:** Include the server's current RNG seed in the capture output (requires the server to send the seed in the `ObserverSnapshot` or via a metadata message). Output as `seed.txt` in the bug report directory.
- **Room metadata:** Already present in MVP — confirm it includes the room name and map identifier.
- Output directory format is unchanged: `tests/bug-reports/gauntlet-t{tick}-{timestamp}/`.
- Integration: The `inputs.jsonl` output must be valid input for `tooling/test-client --replay <file>` (Sprint 10 #483). Test this by replaying a captured session against a fresh server — it should reproduce the same snapshot sequence.
- The ring buffer must add negligible per-tick cost — pre-allocate the 60-slot arrays at startup. Do not allocate on every tick.
- Non-obvious: The server RNG seed may need a new field in `ObserverSnapshot` or a dedicated handshake message. Coordinate with the server team if a protocol change is needed. If the seed is already present in an existing field, document which field.
**#522 — Resolve OQ-07: no-insert interaction behavior (diegetic test)**
- Existing: D-056 cursor states and D-057 entity interaction both reference the diegetic test: interaction labels render on z-layer 6 (insert overlay) and disappear if the insert is off. `client/scripts/rendering/cursor_renderer.gd` handles cursor state transitions. The entity interaction vertical list is in `client/scripts/rendering/` (likely `entity_renderer.gd` or a dedicated interaction UI file, per #432).
- Deliver: Resolve OQ-07 with one of three options, implement, and document:
- **(a) Cursor reverts to default shape only, no verb labels** — cursor state machine still fires (shape changes: default → entity hover bracket or X-shape on object hover), but the tooltip/verb label layer is suppressed because z-layer 6 (insert overlay) has no data to render. This is the most diegetically consistent option: the character still physically orients to the target, but receives no information from their insert.
- **(b) Cursor does not change at all** — insert-off mode means the cursor_renderer.gd stays in Default state regardless of what the cursor is over. Full suppression.
- **(c) Non-insert fallback** — prompts appear via a non-insert visual channel (e.g., world-space text at z-layer 2 or 3). Least diegetically consistent; least likely to be the right answer given D-056's "diegetic test" framing.
- After resolution: amend the relevant decision (D-056 or D-057) with the OQ-07 resolution note, and update `client/scripts/rendering/cursor_renderer.gd` and the interaction list renderer to enforce the chosen behavior when `insert_active == false`.
- This is primarily a design resolution + small implementation task. Effort: 0.5d for decision, up to 0.5d for code.
---
## Dependency Chain
```
#507 (WRONG button full captures) — standalone (#495 done), parallel
#522 (OQ-07 resolution) — standalone, resolve day 1-2
```
Both tickets are independent. No cross-team blockers. Client can run both tracks in parallel from day 1.
**Watch:** If #507 requires a new field on `ObserverSnapshot` for the RNG seed, that is a server protocol change. Raise with the server team immediately — do not wait until the ticket is otherwise complete.
---
## 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 11 combine — client" \
--description "body" --base main --head client
```
+137
View File
@@ -0,0 +1,137 @@
# Sprint 11: Combine — Joint / Integration
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Agents:** Dudley, Tyre, Hoshe (server); Stig, Tyre, Hoshe (client)
---
## Pre-Sprint Decisions
| Item | Status | Needed by |
|------|--------|-----------|
| OQ-18 resolution (#524) | Resolve day 1-2. Output: new D-NNN decision in `decisions/`. May require update to dialogue filter in #305. | Downstream content work (unblocked immediately on resolution) |
| OQ-07 resolution (#522) | Resolve day 1-2. Output: D-056 or D-057 amendment. | #507 (WRONG button — cursor state data in captures) |
| RNG seed in ObserverSnapshot | Client team must confirm whether seed is already present in the snapshot wire format. If not, server team must add it. Raise on day 1. | #507 (replay seed capture) |
No other decisions are required before Sprint 11 begins. All other tickets operate within confirmed decisions.
---
## Cross-Team Dependencies
```
Server #504 (Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
→ Server #506 (cross-room transitions T1-T8)
Client #507 (WRONG button full captures)
→ potential Server protocol change (RNG seed field on ObserverSnapshot)
raise day 1 if needed; server team to respond same day
Server #524 (OQ-18 resolution)
→ no client code change, but unblocks copy team content work
Client #522 (OQ-07 resolution)
→ cursor_renderer.gd + interaction list renderer update (client-only)
```
**Critical path:** `Server #504 → Server #506`. Server team should start #504 immediately. All other tickets are independent parallel tracks.
**Potential protocol dependency:** If #507 requires adding a seed field to `ObserverSnapshot`, this is a server change that the client depends on. Identify this on day 1 and route it through the server team as a minimal, additive struct field with `#[serde(default)]` so the client handles older snapshots gracefully during development.
---
## Sprint Completion Proof
Sprint 11 is complete when all of the following are observable:
**Gauntlet expansion (server #504)**
- Server boots with Gauntlet content and all 10 rooms load without panic (7 from Sprint 10 + 3 new: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
- Room constants for all 3 new rooms in `server/src/test_world/constants.rs`
- Sprint Gauntlet: player sprints past an NPC → interaction buffer empty during sprint, anomaly monologue fires retroactively post-sprint
- Eavesdrop Alcove: player in Careful stance at eavesdrop position → World SFX boost observable in log, conversation murmur event emitted
- Confrontation Stage: player confronts NPC → peripheral NPC movement during confrontation does NOT trigger anomaly monologue; delayed "did I miss something?" monologue fires post-confrontation
**Cross-room transitions (server #506)**
- `make test-server` includes 8 cross-room transition tests (T1-T8)
- All 8 pass: knowledge state, inventory, fog carry-over, and stance all survive room transitions correctly
- T2 (Inventory Interact): item `CarriedBy` survives transition; item absent from tile queries in new room; present in player inventory in snapshot
**Content scaling (server #513)**
- `make test-server` includes the 100-tick max-NPC stress test
- Test passes: every tick within D-026 100ms budget at maximum Active tier entity count (80 NPCs)
- Behavioral regression check passes: baseline NPC subset matches `tests/perf/baseline.json` at tick 100
**Contraband detection (server #425)**
- NPC with `ScanAuthority` within interaction range of player carrying a `Contraband`-flagged item → NPC KG updated with `HasContraband` fact at `KnowsDetails` confidence
- Scan event present in ObserverSnapshot for that tick
- No scan event emitted when player carries no contraband items
- BTreeMap used for KG storage (D-010 determinism, D-041)
**OQ-18 resolved (server #524)**
- New D-NNN decision committed to `decisions/` (content.md or architecture.md as appropriate)
- Decision states clearly whether dialogue access tiers and D-041 confidence tiers are the same system or layered
- If #305 dialogue filter changed: `make test-server` still passes
**WRONG button full captures (client #507)**
- F12 during Gauntlet play → bug report directory contains: `inputs.jsonl` (60 ticks of PlayerInput history), `snapshots.jsonl` (60 ticks of ObserverSnapshot history), `seed.txt` (server RNG seed), plus existing MVP files (text render, description)
- `tooling/test-client --replay inputs.jsonl` against a fresh server with matching seed reproduces the same snapshot sequence
- Ring buffer pre-allocated at startup — no per-tick allocation in hot path
**OQ-07 resolved (client #522)**
- D-056 or D-057 amended with OQ-07 resolution note
- `client/scripts/rendering/cursor_renderer.gd` enforces the chosen insert-off behavior
- Interaction verb labels absent (or reduced per chosen option) when insert is off in Gauntlet test mode
---
## Test Plan Alignment (D-030)
| D-030 Phase | Coverage | Sprint 11 Status |
|-------------|----------|-----------------|
| Phase 1 (infra) | Done Sprints 1-2 | — |
| Phase 2 (integration) | Done Sprints 3-5 | — |
| Phase 3 (CauseChain, divergent snapshots) | Gauntlet golden suite expanded Sprint 9-10 | Expanding with 3 new rooms (#504) |
| Cross-room transitions | T1-T8 from Gestalt (#506) | In scope this sprint |
| Replay / scripted testing | Replay loading done (#483, Sprint 10) | WRONG button ring buffer feeds replay system (#507) |
| Performance regression | Baseline committed (#499, Sprint 10) | 100-tick stress test adds to baseline (#513) |
| Pre-PR gate | `make pre-pr` stable | Unchanged |
---
## Sequence Recommendation
**Days 1-2 (all teams, parallel):**
- Server: start #504 immediately (no blockers). Sprint Gauntlet is the critical path node.
- Server: start #524 (OQ-18 resolution) on day 1. Write decision by end of day 2.
- Server: start #513 (stress test) in parallel — standalone, no dependency on #504.
- Server: start #425 (contraband detection) in parallel — standalone.
- Client: start #522 (OQ-07 resolution) on day 1. Implement and amend decision by end of day 2.
- Client: start #507 (WRONG button) on day 1 — confirm RNG seed availability first.
**Days 3-5:**
- Server: complete all 3 rooms in #504 and verify server boots without panic. Hand off to #506.
- Server: start #506 (cross-room transitions) once #504 is merged.
- Client: #507 ring buffer implementation complete; end-to-end replay test.
**Days 6-10:**
- Server: complete #506 (T1-T8 tests pass).
- All teams: verify 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.
```bash
# Server
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): sprint 11 combine — server" \
--description "body" --base main --head server
# Client
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): sprint 11 combine — client" \
--description "body" --base main --head client
```
+103
View File
@@ -0,0 +1,103 @@
# Sprint 11: Combine — Server Tasks
**Goal:** Complete the Gauntlet test matrix with final interaction-combination rooms and cross-room scenarios, deliver the WRONG-button bug-capture system, resolve open implementation questions OQ-07 and OQ-18, and add contraband detection — hardening the system as a fully covered, testable unit.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (arch review), Hoshe (QA)
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #504 | Gauntlet rooms: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage | #487 (done) |
| #506 | Cross-room transition test scenarios (T1-T8 from Gestalt) | #498 (done), #504 |
| #513 | Content scaling stress test (100 ticks, max-NPC pack, tick budget) | #487 (done), #499 (done) |
| #425 | Contraband detection — NPC scan checks carried items + KG | #424 (done) |
| #524 | Resolve OQ-18: dialogue access tiers vs D-041 confidence hierarchy | — |
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 — 4-level confidence hierarchy, BTreeMap requirement, dialogue tier mapping)
- `decisions/perception.md` — D-055 (sprint suppresses interaction buffer — Sprint Gauntlet room), D-059 (fog/shader five layers — Eavesdrop Alcove), D-060 (cognitive delay — Eavesdrop Alcove), D-070 (confrontation as cognitive vulnerability — Confrontation Stage)
- `decisions/content.md` — D-028 (dialogue access layers — OQ-18 resolution), D-065 (contraband spec)
- `decisions/scope.md` — D-065 (contraband detection scope)
---
## Open Questions to Resolve Early
- **OQ-18 (#524): Dialogue access tiers vs D-041 confidence hierarchy** — Resolve in the first half of the sprint. The decision affects #305 (dialogue selection pipeline filtering logic) which may need updating. Document result as a new D-NNN decision. Consult Gestalt and Nigel on the archetype dimension before closing.
---
## Notes
**#504 — Gauntlet rooms: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage**
- Existing: `server/src/test_world/rooms/` has 7 rooms: `hub.rs`, `fog_theater.rs`, `occlusion_corridor.rs`, `inventory_warehouse.rs`, `interaction_gallery.rs`, `pause_chamber.rs`, `dialogue_room.rs`, `crowd_plaza.rs`. Constants live in `server/src/test_world/constants.rs`. The `ROOMS` array there is additive-only — append new entries at the end or StableId ranges will shift and break all existing golden files.
- Deliver three new room modules, each following the exact pattern of existing rooms:
- **Sprint Gauntlet** (~28x20): Tests D-055 sprint suppression. Player sprints past a visible NPC; interaction buffer must be empty during sprint, anomaly monologue must fire retroactively after sprint ends. Include a sprint-trigger tile and a reset plate.
- **Eavesdrop Alcove** (~24x16): Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful stance at a corner within audible range of two NPCs in conversation. Test: World SFX boost observable, conversation murmur event emitted (D-072), interaction buffer suppressed at eavesdrop distance.
- **Confrontation Stage** (~32x24): Tests D-070 confrontation vulnerability. Player confronts an NPC target; peripheral NPC movement that occurs during confrontation should not trigger anomaly monologue (suppressed by confrontation dip). Post-confrontation delayed monologue fires for the missed event.
- Each room needs constants in `server/src/test_world/constants.rs` (GauntletRoom struct + StableId range). Append after `RESET_PLATE_STABLE_IDS`. Update `ROOMS` array and `EXPECTED_ENTITY_COUNT`.
- Blocks #506 — complete #504 before the sprint midpoint.
**#506 — Cross-room transition test scenarios (T1-T8 from Gestalt)**
- Existing: `tooling/test-client/src/replay.rs` is complete (Sprint 10 #483). `tooling/test-client/src/golden.rs` has snapshot diffing infrastructure. All 7 Sprint 10 rooms (#498) are done; all 3 new Sprint 11 rooms (#504) must be done first.
- Deliver 8 cross-room transition tests covering system combination bugs at room boundaries. Source: `gestalt-round3.md` scenarios T1-T8. Approximate scope:
- **T1 Sprint Exit:** Player sprints out of Sprint Gauntlet room into corridor, asserts interaction buffer clears on entry, restores on exit.
- **T2 Inventory Interact:** Player picks up item in Inventory Warehouse, carries it to adjacent room, verifies CarriedBy survives room transition (tile presence removed, item appears in player inventory in new room snapshot).
- **T3 Pause Anywhere:** Player pauses mid-corridor (between rooms), asserts tick stops, resumes cleanly.
- **T4-T8:** Refer to `gestalt-round3.md` for full scenario specs (knowledge state persistence, fog carry-over, eavesdrop cut on transition, confrontation interrupt on room change).
- Write tests in `server/tests/` or as a dedicated integration test file. Tests must use the replay system or `cargo nextest` ECS world setup — no ad-hoc test harness.
- Blocked by #504. Start after Sprint Gauntlet, Eavesdrop Alcove, and Confrontation Stage are wired and the server boots without panic.
**#513 — Content scaling stress test (100 ticks, max-NPC pack, tick budget)**
- Existing: `server/src/content/` has the content loader. `tooling/perf-baseline` script exists (Sprint 10 #499). The baseline NPC pack is in place. `tests/perf/baseline.json` committed.
- Deliver: A test that spawns the maximum entity count (Active tier ceiling per D-026: 80 NPCs), runs 100 ticks, and asserts that each tick completes within the 100ms budget. Also asserts no behavioral regression on the baseline NPC subset (same positions and knowledge state at tick 100 as in the baseline run). Run via `make test-server` — not a separate script.
- Integration: Compare output against `tests/perf/baseline.json`. The perf baseline tool (#499, done) already defines the output format; match it.
- Standalone, no cross-team dependency. Can run in parallel with #504.
**#425 — Contraband detection: NPC scan checks carried items + KG**
- Existing: `server/src/simulation/inventory.rs` has `CarriedBy`, `InventorySlot`, `ItemName`, and `collect_inventory_for()`. The inventory system is complete (#424 done). D-065 defines contraband as unlicensed lattice components. `server/src/knowledge/` has `graph.rs`, `registry.rs`, `types.rs`.
- Deliver: An NPC scan system that, when an NPC with scan authority is within interaction range of the player, queries the player's carried items for contraband flag. If a contraband item is in inventory, update the NPC's KnowledgeGraph (`KnowsDetails`, source `DirectObservation`) for the player entity with fact: `HasContraband`. Emit a scan event to the ObserverSnapshot for client-side rendering (no client ticket required — use existing event wire).
- Specifics: Define a `Contraband` marker component on item entities. Define `ScanAuthority` component on NPC entities with scan permissions. The scan check runs in a dedicated system scheduled after the perception phase, before the snapshot phase. Use `StableId` references throughout — no raw bevy Entity handles in the knowledge entry.
- Standalone. No cross-team dependency.
**#524 — Resolve OQ-18: dialogue access tiers vs D-041 confidence hierarchy**
- Existing: D-028 defines 4 dialogue access layers: surface/real/secret/unprompted. D-041 defines 4 KG confidence tiers: `Suspects < KnowsOf < KnowsDetails < Direct`. The dialogue selection pipeline (#305) already filters by confidence tier. OQ-18 asks whether character-archetype dialogue access tiers are the same system or a layered system on top.
- Deliver: A written decision (commit to `decisions/content.md` or `decisions/architecture.md` as appropriate as a new D-NNN) resolving one of: (a) same system — archetype access tiers map directly 1:1 to KG confidence tiers (surface=Suspects+, real=KnowsOf+, secret=KnowsDetails+), or (b) layered — character archetype adds a second gating dimension independent of KG confidence (e.g., detective can always access surface regardless of KG confidence because institutional authority, smuggler needs KnowsOf minimum for surface because outsider). If the decision changes the filtering logic in #305, update that system accordingly.
- This is a design resolution ticket, not a large implementation task. The output is a decision document + any follow-on code change to the dialogue filter. Time estimate: 0.5d for discussion + decision write-up, up to 1d if #305 needs updating.
- Resolve early — day 1 or 2 — so downstream content work can proceed with confidence.
---
## Dependency Chain
```
#504 (Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage)
#506 (cross-room transitions T1-T8)
#513 (stress test 100 ticks) — standalone, parallel
#425 (contraband detection) — standalone, parallel
#524 (OQ-18 resolution) — standalone, resolve day 1-2
```
**Critical path:** `#504 → #506`. Complete #504 before sprint midpoint. All other tickets are parallel tracks.
---
## 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 11 combine — server" \
--description "body" --base main --head server
```
+70
View File
@@ -0,0 +1,70 @@
# Sprint 12: Build — CI Tasks
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Branch:** `ci`
**Agents:** Justine (build/deploy)
## Carry-over from Sprint 11
None. Sprint 11 was 7/7 done.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #343 | Ban HashMap in simulation crate via clippy | — |
| #344 | Set up tracing crate infrastructure | — |
| #346 | System dependency graph debug command | — |
| #527 | Add rng_seed field to ObserverSnapshot for deterministic replay | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot + Rust IPC, bridge types), D-041 (knowledge graph, observer snapshot contract)
- `decisions/scope.md` — D-030 (testing architecture — D-phase alignment)
## Notes
**#343 — Ban HashMap in simulation crate via clippy**
- Critical priority. Deterministic simulation requires deterministic iteration order; `std::collections::HashMap` does not guarantee this.
- Add `clippy::disallowed_types` to `.clippy.toml` (or `.cargo/config.toml`'s `[target.*.rustflags]` section) scoped to the `simulation` crate only.
- Approved alternatives: `BTreeMap` (ordered) or `IndexMap` from the `indexmap` crate (insertion-ordered, deterministic).
- This lint will surface existing violations. Each violation in `server/src/simulation/` needs a follow-up fix — file separate tickets for each if the count is large, or fix inline if small.
- CI must fail on new violations after this is merged.
**#344 — Set up tracing crate infrastructure**
- Add `tracing` and `tracing-subscriber` to `server/Cargo.toml`.
- Initialize in `server/src/main.rs` with a stdout subscriber in dev (pretty format) and JSON in CI.
- Add structured log points to: tick duration (`tracing::info!(tick_ms = ...)`), per-system timing (wrap heavy systems with `tracing::instrument`), bridge I/O metrics, tier transition events.
- This is prerequisite instrumentation for tier system debugging (Sprint 12 server work in #99).
- In CI (`make ci-server`), the JSON log output should be captured but not asserted on — just confirm the binary runs without panic.
**#346 — System dependency graph debug command**
- Implement a `--dump-schedule` CLI flag (or `make debug-schedule` Makefile target) that prints the bevy_ecs system ordering and component access patterns.
- Use bevy's `World::resource::<Schedules>()` or the `bevy_app::App::render_schedule_graph()` approach (check bevy 0.14/0.15 API — confirm current version in `server/Cargo.toml`).
- Output: plain text or DOT format listing systems in execution order with their component read/write access.
- CI integration: run on each PR, save output as artifact, diff against baseline to catch unintended system reordering.
- Lives in `server/src/main.rs` (flag) or a dedicated `server/src/debug.rs` module.
**#527 — Add rng_seed field to ObserverSnapshot for deterministic replay**
- The WRONG button (#507, done Sprint 11) writes `inputs.jsonl` and `seed.txt` for replay, but `seed.txt` currently writes `"unavailable"` because the server does not include `rng_seed` in `ObserverSnapshot`.
- Fix: add `rng_seed: Option<u64>` to the `ObserverSnapshot` struct in `server/src/bridge/types.rs`.
- Populate it from the simulation's RNG state on each tick. The RNG system lives in `server/src/simulation/rng.rs`.
- This is a server-side change committed from the CI branch. Coordinate with server team to avoid conflict on `bridge/types.rs` — or implement as a separate additive commit that merges cleanly.
- Completes the WRONG button capture loop. After this, replays can fully reproduce observed bugs.
## Dependency Chain
```
#343, #344, #346, #527 — all standalone, all parallel
```
## 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 12 — HashMap lint, tracing infra, schedule debug, rng_seed fix" --description "body" --base main --head ci
```
+76
View File
@@ -0,0 +1,76 @@
# Sprint 12: Build — Client Tasks
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Branch:** `client`
**Agents:** Stig (client dev), Tyre (architect), Hoshe (QA)
## Carry-over from Sprint 11
None. Sprint 11 was 7/7 done.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #125 | Close-range stereo audio | #124 (server) |
| #126 | Medium-range visual indicators | — |
| #345 | Fix entity_renderer.gd field name bug | — |
| #447 | Resolve OQ-29: dialogue max-width pixel value | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (IPC bridge), D-066 (dual-scale grid)
- `decisions/perception.md` — D-018 (three-range sound model), D-067 (recognition chime at cognitive delay onset), D-068 (5-bus audio architecture), D-069 (audio dip profiles), D-071 (ListeningFocus boost for eavesdropping)
## Open Questions to Resolve Early
- **OQ-29** (#447): Dialogue max-width pixel value. Resolve before any dialogue layout work. Fast resolution — pick a value, record the decision.
## Notes
**#125 — Close-range stereo audio**
- Blocked by #124 (server sound event system). Start once server delivers `sound_events` in `ObserverSnapshot`.
- The `audio_manager.gd` autoload (`client/scripts/autoloads/audio_manager.gd`) already exists from Sprint 8. This ticket wires it to the snapshot-driven event loop.
- Close-range events (RangeCategory::Close) map to 2D positional audio using Godot's `AudioStreamPlayer2D`. Screen-space coordinates from the entity's world position.
- Asset registry: `AudioAssetRegistry` maps event type (`Footstep`, `Voice`, etc.) to audio file. The 8 audio assets from D-038 (done Sprint 10) are available — use them.
- D-068 specifies the 5-bus architecture (Master, Ambient, World SFX, Player Actions, UI). Sound events go on the **World SFX** bus.
- D-069 audio dip: during confrontation, World SFX drops 46dB. The dip logic is in `audio_manager.gd` — confirm it's wired to the confrontation state flag.
**#126 — Medium-range visual indicators**
- Fog-edge directional indicators for sound events outside LOS (RangeCategory::Medium).
- Arrow or icon at the fog boundary, pointing toward sound source direction. Color-coded per D-018 and D-069: neutral `#c8d0e0`, voices `#e8c547`, danger `#d45d5d`.
- Triggers an internal monologue description (server-side, but client shows the indicator at the fog edge).
- Implementation: overlay node on top of `fog_renderer` (`client/scripts/rendering/fog_renderer.gd.uid` — confirm file name). Draw using `_draw()` or a dedicated indicator scene.
- Does not require #124 to be complete — can implement the indicator rendering standalone with mock data, then wire to snapshot.
- Visual spec for the fog-edge pulse design (#317, visual team) is in sprint but may not arrive before implementation. Use the D-018 color values as the reference.
**#345 — Fix entity_renderer.gd field name bug**
- Known bug: `entity_renderer.gd` references `entity.id` but the bridge protocol uses `entity.entity_id`.
- File: `client/scripts/rendering/entity_renderer.gd`.
- Fix the field name, add a regression test to the client test suite.
- Quick fix — do this first to unblock clean rendering for the sprint.
**#447 — Resolve OQ-29: dialogue max-width pixel value**
- Open question: what is the max pixel width for the dialogue box?
- Resolution approach: measure against the target resolution, pick a value that fits the grid. Document in `client/scripts/constants.gd` as `DIALOGUE_MAX_WIDTH`.
- Record the resolved value as a decision if it has downstream impact (it does — affects text wrapping in dialogue UI).
## Dependency Chain
```
#124 (server, sound events) → #125 (close-range stereo audio)
#126 (medium-range indicators) — standalone, parallel
#345 (field name bug) — quick fix, do first
#447 (OQ-29 resolution) — standalone, do early
```
## 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 12 — sound pipeline, medium-range indicators, renderer fix" --description "body" --base main --head client
```
+211
View File
@@ -0,0 +1,211 @@
# Sprint 12: Build — Copy Tasks
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Branch:** `copy`
**Agents:** Mellanie (author), Paula (narrative), Gestalt (systems), Miri (cultural consultant)
## Carry-over from Sprint 11
None. Sprint 11 was 7/7 done.
## New Tickets
### Foundation docs (unblock everything else — do these first)
| # | Title | Blocked by |
|---|-------|------------|
| #368 | Knowledge vocabulary for v0.1 content | — |
| #189 | Cultural generation guide — Miri | — |
| #302 | Sova Texture Appendix | — |
| #321 | Contraband specification — lattice components + supply chain | — |
| #179 | Character definition schema | — |
| #180 | Smuggler character build | — |
| #181 | Detective character build | — |
| #182 | Divergent starting knowledge | — |
| #183 | Divergent relationships | — |
| #322 | Detective institutional chain of command | — |
| #320 | Sova Station Profile | — |
| #336 | Span Gate Transit Schedule | — |
### Setting texture docs
| # | Title | Blocked by |
|---|-------|------------|
| #335 | Meridian Coverage Map for Sova Transit | #320 |
### Design pattern docs (already unblocked)
| # | Title | Blocked by |
|---|-------|------------|
| #332 | Contradiction arc design document — FRIEND pattern | — |
| #329 | Mirror moment design document | — |
| #259 | First 5 minutes experience design | — |
### Knowledge and vocabulary (Gestalt)
| # | Title | Blocked by |
|---|-------|------------|
| #309 | Knowledge state vocabulary for v0.1 | #368 |
### Content packs — Mellanie (depend on foundation docs and line previewer #193 from server)
| # | Title | Blocked by |
|---|-------|------------|
| #190 | Workplace content pack | #189, #193 (server), #302, #321, #311 (visual) |
| #191 | Bar content pack | #189, #193 (server), #302, #321, #312 (visual) |
| #192 | Smuggling ring content pack | #189, #193 (server), #302, #321, #313 (visual) |
### Opening content (depend on #309)
| # | Title | Blocked by |
|---|-------|------------|
| #260 | Opening hook content per character | #259 |
| #299 | Opening hook content — smuggler first 5 minutes | #309 |
| #300 | Opening hook content — detective first 5 minutes | #309 |
| #307 | Flat NPC memorable trait pass — 3 flat NPCs | — |
### Supporting content (depend on earlier tickets)
| # | Title | Blocked by |
|---|-------|------------|
| #306 | News ticker / Meridian feed content — 20-30 lines | #302 |
| #262 | Environmental text content | — |
| #331 | Diegetic insert flavor text — per character | — |
| #330 | Diegetic tutorial monologue lines — per character | #299, #300 |
| #194 | Generation pass expansion | — |
### Parent epic (close when all children done)
| # | Title | Blocked by |
|---|-------|------------|
| #369 | v0.1 Content Scoping Workshop Outputs (epic) | children above |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-023 (YAML content format), D-024 (three-tier content pipeline), D-025 (dialogue access tier model), D-028 (internal monologue pools per character), D-029 (THE FRIEND production NPC pattern), D-034 (D-029 confirmed), D-035 (knowledge state vocabulary), D-050 (Velen / Krenn system), D-062 (dialogue system architecture), D-063 (confrontation — same box different weight), D-064 (opening experience design)
- `decisions/perception.md` — D-016 (internal monologue system), D-032 (separate monologue pools per character)
- `decisions/scope.md` — D-027 (vertical slice), D-036 (Sova Transit District / Krenn as v0.1 setting), D-037 (contraband spec — unlicensed lattice components), D-039 (v0.1 wow moments — all 6)
- `decisions/architecture.md` — D-041 (knowledge graph — provides vocabulary foundation via Appendix A)
## Open Questions to Resolve Early
- **#368#309 dependency:** Gestalt writes vocabulary doc (#368) first. Paula/Mellanie need #309 (knowledge flags) before writing opening hooks #299/#300. Sequence: #368#309#299/#300.
- **Content pack dependency on visual spatial layouts:** #190/#191/#192 are blocked by #311/#312/#313 (Araminta's spatial layouts, visual team). These are a cross-team dependency. If visual spatial layouts slip, content packs must still proceed with draft versions and be finalized once layouts are confirmed.
## Notes
**Sequencing for the sprint — recommended wave order:**
Wave 1 (all parallel, no blockers):
- #368 (Gestalt) — knowledge vocabulary doc
- #189 (Miri) — cultural generation guide
- #302 (Miri) — Sova Texture Appendix
- #321 (Miri) — contraband spec
- #179/#180/#181 (Paula) — character definition schema and builds
- #182/#183 (Paula) — divergent knowledge/relationships
- #322 (Paula) — detective chain of command
- #320 (Miri) — Sova Station Profile
- #336 (Miri) — Span Gate Transit Schedule
- #332 (Paula) — Contradiction arc doc (FRIEND pattern)
- #329 (Paula) — Mirror moment doc
- #259 (Paula) — First 5 minutes experience design
- #307 (Mellanie) — Flat NPC memorable trait pass
- #262 (Mellanie) — Environmental text content
- #331 (Mellanie) — Diegetic insert flavor text
Wave 2 (after Wave 1 foundation docs):
- #335 (Miri) — Meridian Coverage Map (blocked on #320)
- #309 (Gestalt) — Knowledge state vocabulary (blocked on #368)
- #260 (Paula) — Opening hook content per character (blocked on #259)
- #306 (Mellanie) — News ticker / Meridian feed (blocked on #302)
Wave 3 (after Wave 2 + line previewer #193 from server + spatial layouts from visual):
- #190 (Mellanie) — Workplace content pack
- #191 (Mellanie) — Bar content pack
- #192 (Mellanie) — Smuggling ring content pack
- #299 (Mellanie/Paula) — Opening hook smuggler (blocked on #309)
- #300 (Mellanie/Paula) — Opening hook detective (blocked on #309)
Wave 4 (final):
- #330 (Mellanie) — Diegetic tutorial monologue (blocked on #299, #300)
- #194 (Mellanie) — Generation pass expansion (LLM-assisted, after packs exist)
**#368 — Knowledge vocabulary for v0.1 content**
- Owner: Gestalt. Source material: D-041 Appendix A (knowledge graph workshop synthesis).
- Deliver as a YAML or Markdown doc in `docs/design/` (or wherever content docs live).
- Categories: entity knowledge (identity, location, behavior, relationship, secret, contraband), world knowledge, prerequisite flag naming conventions (`knows:`, `suspects:`, `met:`, `has_seen:`).
- This is the foundation for #309 (Paula's knowledge flag list) which unblocks #299/#300.
**#189 — Cultural generation guide — Miri**
- Blocking dependency for all three content packs (#190, #191, #192).
- Sova Transit District / Krenn System is the FIRST concrete instance — Miri's output IS the v0.1 cultural voice.
- Covers: naming conventions, economic vocabulary (what do workers call credits, shifts, cargo), social norms, relationship dynamics in a Commission-regulated transit hub.
- D-036 confirms Sova Transit District as setting. D-050 establishes Velen/Krenn context.
**#302 — Sova Texture Appendix**
- 1-page appendix to the Sova setting brief. Fast to write, high leverage — unlocks #190, #191, #192, #306.
- (1) Slang glossary 1520 terms, (2) informal decoration (graffiti, stickers), (3) smells and sounds, (4) recurring social rituals.
- Write as companion to #189 in the same pass.
**#321 — Contraband specification**
- D-037 confirms: primary contraband = unlicensed lattice components (aftermarket neural mods bypassing Commission regulation).
- Deliverable: spec doc covering types, supply chain, street names, detection methods.
- Unlocks #190, #191, #192 — Mellanie needs this before writing any criminal-facing dialogue.
**#179/#180/#181 — Character definition schema and builds**
- #179: formal schema structure (what fields define a character — starting knowledge, relationships, access permissions, skill flags).
- #180/#181: Smuggler and Detective as the two concrete instances.
- These are design documents, not code. Owner: Paula.
- #182 (divergent starting knowledge) and #183 (divergent relationships) follow from #180/#181.
**#190/#191/#192 — Content packs**
- The three primary content packs. Each ~165280 lines.
- #190 Workplace: cargo workers, supervisors, shift change scripts, manifest disputes.
- #191 Bar: bartender, regulars, gossip pools, social dialogue (40% more lines than investigation).
- #192 Smuggling ring: criminals, trust-gated disclosure, coded language, paranoia.
- All blocked by: #189 (Miri), #193 (line previewer from server team), #302 (texture appendix), #321 (contraband spec), plus respective spatial layouts (#311, #312, #313 from visual team).
- The line previewer (#193) is a Rust CLI being built by server team this sprint. Coordinate with Dudley/Tyre on delivery timing.
**#299/#300 — Opening hook content**
- First 1015 tightly sequenced monologue lines per character.
- Smuggler: diegetic tutorial for movement, fog, NPC interaction. Establishes criminal motivation.
- Detective: different emotional register. Arrives into established rhythm. First NPC is adversarial by default.
- Blocked by #309 (knowledge flags) — lines need to fire on `knows:` prerequisites.
**#307 — Flat NPC memorable trait pass**
- 3 flat NPCs: Pael (maintenance tech), Ren (drifter), Tev (lookout).
- Each needs: one memorable observable trait, one "seems important but isn't" line, one visible routine.
- These are the investigation noise floor — without them the 30/50/20 structure (30% FRIEND, 50% background, 20% flat) collapses.
**#332 — Contradiction arc design document**
- Reusable pattern from THE FRIEND implementation (#297 Kael, #298 Sera — both done).
- Document the pattern: 3+ phases with distinct monologue registers, observable contradiction discoverable through observation (not dialogue).
- Owner: Paula. Already unblocked.
**#329 — Mirror moment design document**
- 1015 paired observation triggers. Same event, different character monologue.
- 7 core mirrors already designed (per ticket): The Friend's Routine, The Empty Corridor, The Overheard Argument, The Familiar Face, The Contraband Scan, The End of Shift, The Missing Person Notice.
- Owner: Paula. Already unblocked.
## Dependency Chain
```
#368 (knowledge vocab) → #309 (knowledge flags) → #299, #300 (opening hooks) → #330 (tutorial monologue)
#189 (Miri guide) + #302 (texture appendix) + #321 (contraband) + #193 (server) + #311/#312/#313 (visual) → #190, #191, #192 (content packs)
#259 (5 min design) → #260 (opening content per character)
#320 (station profile) → #335 (Meridian coverage map)
#179#180, #181#182, #183
#332, #329 — standalone (already unblocked)
#307, #262, #331, #336 — standalone
```
## 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(copy): Sprint 12 — complete v0.1 content authoring" --description "body" --base main --head copy
```
+92
View File
@@ -0,0 +1,92 @@
# Sprint 12: Build — Joint / Integration Notes
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Sprint 12 ID:** 12
**Status:** planning (activate with `db/connectors/sprint start`)
---
## Pre-Sprint Decisions
No blocking open decisions. The following decisions are implemented this sprint and should be cross-referenced:
| Decision | Domain | Implementing ticket(s) |
|----------|--------|----------------------|
| D-041 (knowledge graph) | architecture | #138, #139 (KG access control) |
| D-018 (three-range sound) | perception | #124 (server), #125, #126 (client) |
| D-068 (5-bus audio architecture) | architecture | #125 (client wiring) |
| D-043049 (art direction suite) | perception | #303 (visual grammar doc) |
| D-033, D-052 (entity color) | perception/content | #303 section 3 (covers #304) |
| D-066 (dual-scale grid) | architecture | #93, #94, #99 (tier system spatial indexing) |
---
## Cross-Team Dependencies
| Dependency | From | To | Risk |
|------------|------|----|------|
| #124 (sound events in ObserverSnapshot) | server | client #125 | Medium — client can stub; wire when ready |
| #193 (line previewer CLI) | server | copy #190, #191, #192 | High — content packs cannot be finalized without tool |
| #303 (visual grammar) | visual | copy #190, #191, #192 via #311, #312, #313 | High — spatial layouts blocked until doc delivered |
| #311, #312, #313 (spatial layouts) | visual | copy #190, #191, #192 | Medium — wireframes unblock draft content |
| #527 (rng_seed in snapshot) | ci | server bridge/types.rs | Low — additive field, no conflict if added carefully |
**Coordination protocol:**
- Server team: signal when #124 (sound events) and #193 (line previewer) are merged to `server` branch. Copy and client teams are waiting.
- Visual team: deliver wireframe versions of #311, #312, #313 immediately after #303 is done (even if full tile maps come later) so copy team can start content packs.
- CI team on #527: coordinate with server on `bridge/types.rs` — confirm no concurrent edits. Add the field as an additive commit.
---
## Sprint Completion Proof
When Sprint 12 is done, the following is concretely observable:
1. **Simulation tier system:** Spawn the test world, set NPC count to 20. Only NPCs within range of the player have `ActiveSim`; distant NPCs have `BackgroundSim`. Promoting an NPC (by walking toward it) takes <5ms. Demoting takes <5ms. Observable in the debug overlay or log output.
2. **Sound event pipeline end-to-end:** In the gauntlet `dialogue_room`, an NPC conversation emits Voice sound events. Close-range events play 2D positional audio on the client World SFX bus. Medium-range events show a color-coded fog-edge directional indicator. No audio errors in the log.
3. **Knowledge boundary enforcement:** The observer query for an entity tagged `OwnerOnly` does not return that component's data for a non-owner observer. Verifiable via Hoshe's unit tests on #139.
4. **Visual grammar document:** `docs/design/visual-grammar-v01.md` exists with all 7 sections (color palette, entity sizing, entity color system, z-level stack, typography, animation tiers, insert overlay). All three spatial layout documents exist for The Terminal, The Last Shift, and the smuggling corridors.
5. **Line previewer CLI:** Running `cargo run --bin line_preview -- --character smuggler --knows smuggling_operation` against a content YAML returns matching lines and exits 0.
6. **v0.1 copy complete:** All copy tickets in this sprint are `done`. The content packs (#190, #191, #192), opening hooks (#299, #300), and all foundation docs (#189, #302, #321, #309) are committed to the `copy` branch.
7. **CI hardening:** `make ci-server` passes with `clippy::disallowed_types` enforced. `--dump-schedule` flag runs without panic. WRONG button `seed.txt` writes a valid u64, not `"unavailable"`.
---
## Test Plan Alignment (D-030)
Sprint 12 is in the integration phase (Sprints 912). Test focus:
- **Server:** Unit tests for tier system (#93, #94, #99) — promote/demote timing, `With<ActiveSim>` query correctness. Unit tests for access control (#139) — negative tests: blocked component not returned. Sound event emission tests (#124).
- **Client:** Audio playback tests for #125 — verify correct bus routing. Visual indicator tests for #126 — verify indicator renders at correct fog edge position.
- **CI:** Clippy lint enforcement (#343) — CI must fail on new HashMap usage in simulation crate. Tracing (#344) — binary runs without panic, log output captured.
---
## Decision Coverage Gaps (from sprint prepare)
The following confirmed decisions still have no implementing tickets. Flag to Team Leader if any block Sprint 12 work:
- D-031 (time system / game clock) — no ticket yet; relevant to tier transitions (#99) and NPC routines
- D-033 (entity color = relationship) — covered by #303 visual grammar section
- D-038 (audio in v0.1 — 8 files) — assets done (Sprint 10); integration via #124/#125 this sprint
- D-067 (recognition chime at cognitive delay onset) — no ticket; could be added if capacity permits
- D-068 (5-bus audio architecture) — implementing via #125 this sprint
---
## Notes for Sprint Start
To activate the sprint once planning is approved:
```bash
db/connectors/sprint start
```
This sets Sprint 12 to `active` and marks all sprint tickets as `in_progress` where appropriate. Teams should then run `db/connectors/sprint start-work --team <team>` for their full context dump.
+94
View File
@@ -0,0 +1,94 @@
# Sprint 12: Build — Server Tasks
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Branch:** `server`
**Agents:** Dudley (simulation dev), Tyre (architect), Hoshe (QA)
## Carry-over from Sprint 11
None. Sprint 11 was 7/7 done.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #93 | Tier marker components | — |
| #94 | Active tier simulation | #93 |
| #99 | Tier transition logic | #93 |
| #138 | Information tag schema | — |
| #139 | Component-level access control | #138 |
| #124 | Sound event system — server | — |
| #193 | Line previewer CLI | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot + Rust IPC), D-041 (Knowledge Graph data model), D-066 (dual-scale grid: 0.5m sim, 1m visual)
- `decisions/perception.md` — D-017 (perception modes), D-018 (three-range sound model)
- `decisions/scope.md` — D-027 (vertical slice), D-053 (movement stance system)
## Notes
**#93 — Tier marker components**
- Epic #40 (Simulation Tier System) is entirely unstarted — this is the entry point.
- Deliver three zero-sized marker components: `ActiveSim`, `BackgroundSim`, `StateSaved`.
- Add a `TierPlugin` that registers them. Tag-based: systems query `With<ActiveSim>` to scope work to nearby NPCs only.
- Lives in `server/src/simulation/tier.rs` (file exists; currently stubs the stance system — check before overwriting).
- Integration point: all NPC behavior systems in `server/src/simulation/` should gain `With<ActiveSim>` query filters once this exists.
**#94 — Active tier simulation**
- Full behavior systems (movement, perception, dialogue, monologue) run at 1020 ticks/sec for `ActiveSim` NPCs only.
- Practically: add `With<ActiveSim>` filter to the movement, pathfinding, observation, and monologue systems in `server/src/simulation/`.
- No new systems needed yet — this is scope-gating existing ones.
- Blocked by #93 (marker components must exist first).
**#99 — Tier transition logic**
- Promote `StateSaved → BackgroundSim → ActiveSim` when player approaches; demote on departure.
- Budget: 25ms reactivation. Use `SpatialIndex` trait (see `server/src/simulation/` — check if `SpatialIndex` already exists from earlier sprints; if not, #340 defines it but is still backlog — implement the naive Vec version inline for now).
- Tier transitions fire on position-change events. Wire to the existing movement system in `server/src/simulation/movement.rs`.
- Blocked by #93.
**#138 — Information tag schema**
- Completes the knowledge graph work from Sprints 23 (#361367 all done).
- Define `ObserverAccess` enum: `Public`, `OwnerOnly`, `FactionOnly(faction_id)`, `RelationshipGated(threshold)`, `KnowledgeGated(flag)`.
- Add as component metadata attribute — each ECS component that carries sensitive data gets an `#[access = ...]` annotation (or a companion `AccessRule` component).
- Lives in `server/src/knowledge/types.rs` (already exists — add to the existing types file).
- This schema is what #139 enforces.
**#139 — Component-level access control**
- Query filter layer: observer queries (the `observer/` module under `server/src/perception/`) must respect `ObserverAccess` tags before returning data.
- Implement as a filter function on the observer snapshot builder: `filter_by_access(observer_entity, component_access_rule, kg: &KnowledgeGraph) -> bool`.
- The `KnowledgeGraph` component (done, `server/src/knowledge/graph.rs`) provides the knowledge state needed for `KnowledgeGated` checks.
- Blocked by #138.
**#124 — Sound event system — server**
- `SoundEventEmitter` component: emits typed events (`Footstep`, `Voice`, `Machinery`, `Alert`, `Ambient`) with position, intensity, `RangeCategory` (Close/Medium/Long per D-018).
- `SoundEventQueue` resource: collects events each tick, fans out to subscribers (client bridge + NPC awareness).
- Wire into the bridge: sound events within player LOS range → included in `ObserverSnapshot` as a `sound_events` vec.
- Existing audio manager on client (`client/scripts/autoloads/audio_manager.gd`) expects a `sound_events` array in the snapshot — confirm field name matches.
- Unblocked (standalone). #125 on client is blocked on this.
**#193 — Line previewer CLI**
- Rust binary (`tooling/line-previewer` or `server/src/bin/line_preview.rs`) sharing code with the dialogue pipeline.
- MVP: (1) load YAML content pack, (2) set filter context via CLI flags (`--character smuggler --knows smuggling_operation`), (3) print matching lines, (4) `--explain` mode shows why each line matched/filtered, (5) sequence preview for ordered monologue.
- Shares `server/src/content/` types (`LinePool`, `ContentLoader`).
- This tool is what Mellanie needs before authoring content packs — unblocks #190, #191, #192.
## Dependency Chain
```
#93 (tier markers) → #94 (active sim) → #99 (tier transitions)
#138 (access schema) → #139 (access control)
#124 (sound events) — standalone, unblocks client #125
#193 (line previewer) — standalone, unblocks copy #190, #191, #192
```
## 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 12 server — tier system, sound events, KG access control" --description "body" --base main --head server
```
+90
View File
@@ -0,0 +1,90 @@
# Sprint 12: Build — Visual Tasks
**Goal:** Lay the production-layer foundations — simulation tier system, sound event pipeline, visual grammar, and knowledge boundary enforcement — and complete all v0.1 copy authoring so content packs, opening hooks, and world-building docs are done.
**Branch:** `visual`
**Agents:** Araminta (art direction)
## Carry-over from Sprint 11
None. Sprint 11 was 7/7 done.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #303 | v0.1 Visual Grammar Document | — |
| #252 | Placeholder art specification | — |
| #311 | Spatial layout: Logistics Hub (The Terminal) | #303 |
| #312 | Spatial layout: Bar (The Last Shift) | #303 |
| #313 | Spatial layout: Smuggling spaces and transition corridors | #303 |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-043 (art direction — "functional warmth"), D-044 (visual hierarchy: entity > object > structure), D-045 (environmental neutrality — strict zero shift), D-046 (lighting — three-reference model), D-047 (two-tier animation system), D-048 (neural insert overlay visual design), D-049 (z-level rendering stack — 8 layers)
- `decisions/scope.md` — D-036 (Sova Transit District as v0.1 setting)
- `decisions/architecture.md` — D-066 (dual-scale grid: 0.5m simulation, 1m visual)
- `decisions/content.md` — D-052 (character favorite colors — object-layer identification)
## Notes
**#303 — v0.1 Visual Grammar Document**
- This is the sprint's single highest-leverage deliverable. It directly unblocks 11 downstream tickets: #304, #311, #312, #313, #314, #315, #316, #317, #318, #333, #334.
- One document, approximately 46 pages. Contents:
1. **Color palette** — three zone palettes (logistics hub: cool industrial, bar: warm amber, corridors: neutral) with explicit hex values. Cross-reference D-043 "functional warmth" and D-045 environmental neutrality (environment NEVER shifts to signal danger/safety — only entities carry emotional color).
2. **Entity sizing and proportions** — NPC rectangle dimensions at 1m visual grid. Player indicator. Static object sizing.
3. **Entity color system** — relationship states (D-033, D-052): unknown=teal `#4a9ebb`, known/friendly=green `#6bc9a6`, person-of-interest=amber `#e8c547`, hostile=red `#d45d5d`, static objects=grey `#8899aa`. This IS #304 content — write it as a section of #303, then close #304 as covered.
4. **Z-level layer assignments** — per D-049: 8 layers from floor (0) to UI overlay (7). Which layer gets tiles, NPCs, player, fog, UI elements.
5. **Typography baseline** — font (Michroma, introduced Sprint 10), sizes, weights for dialogue, monologue, environmental text, HUD.
6. **Animation tier baseline** — per D-047: Tier 1 (8-frame idle/walk loop for Active NPCs) vs Tier 2 (static sprite for Background NPCs).
7. **Neural insert overlay** — per D-048: the insert HUD aesthetic. Smuggler vs detective visual variants.
- Deliver as `docs/design/visual-grammar-v01.md` (or equivalent path in the visual branch).
- **Write this first.** Everything else in this sprint is blocked on it.
**#252 — Placeholder art specification**
- Define: tile size in pixels (at 1m visual grid per D-066), NPC sprite dimensions, animation frame count requirements, color palette constraints, file format.
- This is a short spec document (12 pages). Unblocks #133 (placeholder art pipeline implementation).
- Can be written in parallel with #303 — it feeds from the same decisions (D-043, D-044, D-066) but doesn't need #303 to be complete first.
**#311 — Spatial layout: Logistics Hub (The Terminal)**
- Tile-level floor plan for the logistics hub. Blocked by #303 (need the visual grammar before doing tile-level design).
- Contents: scanner bays, main corridor (chokepoint — key player observation position), manifest processing area, break room, supervisor office (window overlooking floor), restricted storage entrance.
- Include sightline analysis: which positions have LOS to which areas. This feeds #190 (Mellanie's workplace content pack) and is critical for investigation design.
- D-066 dual-scale grid: draw at 1m visual grid, note that simulation runs at 0.5m internally.
- Deliver as a diagram or annotated tilemap sketch in `docs/design/` with accompanying notes.
**#312 — Spatial layout: Bar (The Last Shift)**
- Converted maintenance staging area. Irregular layout is intentional — organic feel per D-043 "functional warmth."
- Key elements: long bar counter (high visibility from most positions), corner booth (key observation position — can see both entrance and bar), bathroom corridor (secondary exit, NPC private conversations), back room (staff only).
- Same sightline analysis as #311. Feeds #191 (bar content pack).
**#313 — Spatial layout: Smuggling spaces and transition corridors**
- The smuggling ring does NOT have a separate building. Operations run through:
1. Restricted storage within the logistics hub
2. Maintenance corridors (off main paths)
3. Dead-drop locations (3 specific spots)
4. Transition corridors between hub and bar district (~40m)
- Mark the corridors with expected NPC traffic density (sparse/moderate/busy affects how conspicuous player movement is).
- Feeds #192 (smuggling ring content pack).
**Cross-team note:** #311, #312, #313 are blockers for copy team's content packs (#190, #191, #192). Deliver these as early as possible after #303 is done. If full tile maps can't be finalized before content packs start, provide wireframe versions so Mellanie can proceed with draft content.
## Dependency Chain
```
#303 (visual grammar) → #311, #312, #313 (spatial layouts)
#252 (placeholder spec) — parallel, standalone
#311 → unblocks copy #190
#312 → unblocks copy #191
#313 → unblocks copy #192
```
## 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(visual): Sprint 12 — visual grammar, placeholder spec, spatial layouts" --description "body" --base main --head visual
```
+132
View File
@@ -0,0 +1,132 @@
# Test Plan: Sprint 11 Combine — #507 and #522
- **Date**: 2026-02-19
- **Sprint**: 11 (Combine)
- **Spec references**: D-056, D-057, D-030, D-020
- **Tickets**: #507 (WRONG button ring buffer), #522 (OQ-07 no-insert behavior)
- **QA Engineer**: Hoshe
---
## #507: WRONG Button Full Captures
### Spec reference
D-030 (testability), D-020 (ObserverSnapshot boundary). Sprint 11 client.md §Notes #507.
### What's changing from MVP (#495)
MVP (done Sprint 9): F12 → pause → single snapshot → render.txt + snapshot.json + description.txt.
Upgrade: 60-tick rolling ring buffers (inputs + snapshots), seed file, replay compatibility.
### Happy path tests
1. **Ring buffer capacity**: `_get_buffer_capacity()` returns 60.
2. **Buffer pre-allocation**: Buffer array has 60 slots immediately after `_ready()`, no lazy allocation.
3. **Input push fills buffer**: After pushing 10 ticks of inputs, buffer has 10 entries.
4. **Snapshot push fills buffer**: After pushing 10 snapshots, snapshot buffer has 10 entries.
5. **JSONL format — inputs**: `_format_inputs_jsonl()` returns N lines for N ticks pushed, each line is valid JSON array.
6. **JSONL format — snapshots**: `_format_snapshots_jsonl()` returns N lines for N ticks, each line is valid JSON.
7. **JSONL line format matches replay.rs**: Each line is a JSON array of PlayerInput objects (`[{"tick":N,"action":"..."}]`), parseable by `tooling/test-client --replay`.
8. **Empty tick flush**: An idle tick pushes an empty array `[]` to input buffer; flushes as `[]` line.
9. **seed.txt present**: `seed.txt` is written to the report directory on capture.
10. **description.txt unchanged**: Description, tick, room, stance, facing, position all still present.
11. **Directory name unchanged**: `gauntlet-t{tick}-{timestamp}/` format preserved.
12. **Existing files preserved**: `snapshot.json` and `render.txt` still written (MVP files).
### Edge cases
13. **Ring buffer circular overwrite**: After pushing 61 ticks, buffer has 60 entries (oldest evicted, newest kept).
14. **Ring buffer 60 exact**: After pushing exactly 60 ticks, all 60 present, none evicted.
15. **Pre-F12 no inputs**: Before any tick inputs are pushed, flush produces empty or correct minimal JSONL.
16. **Snapshot before any tick**: Flush with no snapshots pushed produces empty JSONL or safe fallback.
17. **Seed missing in snapshot**: If server hasn't sent seed yet, `seed.txt` is written with "unknown" or zero value (not crash).
18. **Directory creation failure**: If `user://bug-reports/` is unwritable, `push_error` fires but no crash.
### Integration tests
19. **Inputs JSONL → replay roundtrip**: JSONL produced by flush is valid input for `tooling/test-client --replay` (format matches `replay.rs` parse contract).
20. **F12 capture → file structure**: Full F12 flow produces expected directory with all 5 files: `snapshot.json`, `render.txt`, `description.txt`, `inputs.jsonl`, `snapshots.jsonl`, `seed.txt`.
21. **Ring buffer does not affect render.txt**: Text render output is unchanged from MVP.
### Performance tests
22. **No per-tick allocation**: Pushing inputs in a tight loop does not allocate new Array objects — buffer reuses pre-allocated slots.
23. **Flush is O(60)**: Flushing 60 entries completes in < 1ms.
### Regression markers
- MVP `_save_report()` behavior: `snapshot.json`, `render.txt`, `description.txt` unchanged.
- F12 → pause → capture → unpause lifecycle unchanged.
- `test_anti_tedium.gd` tests must still pass.
---
## #522: Resolve OQ-07 — No-Insert Interaction Behavior
### Spec reference
D-056 (cursor states), D-057 (entity interaction). OQ-07 resolution.
### Decision context
Three options were proposed. Expected resolution: **(a) cursor reverts to default shape only, no verb labels** — "the character still physically orients to the target, but receives no information from their insert." This is the most diegetically consistent option per D-056's "diegetic test" framing.
Spec contract regardless of which option is chosen: **"If the insert is off, labels disappear."**
### Happy path tests
**Interaction list (z-layer 6) — diegetic test:**
1. **Insert-off hides list**: When `insert_active == false`, interaction list is not visible.
2. **Insert-on shows list**: When `insert_active == true` and verbs are present, list is visible.
3. **Re-enable restores list**: Toggling insert off then on with verbs present shows the list again.
**Cursor (option a — shape changes, labels suppressed):**
4. **Insert-off still transitions cursor shape**: With `insert_active == false`, hovering over an NPC sets cursor to EntityHover state (shape changes, character physically orients).
5. **Insert-off suppresses should_show_interactions()**: `cursor.should_show_interactions()` returns false when `insert_active == false`.
6. **Insert-on restores interactions**: After `set_insert_active(true)`, `should_show_interactions()` returns true (unless in weapon mode).
**Cursor (option b — full suppression, if chosen instead):**
4b. **Insert-off locks cursor to Default**: With `insert_active == false`, hovering over an NPC does NOT change cursor state.
5b. **Same `should_show_interactions()` behavior**.
### Edge cases
7. **Insert off + weapon mode**: `insert_active == false` AND `weapon_mode_active == true``should_show_interactions()` returns false (not double-false confusion).
8. **Insert off + Shift held**: `insert_active == false` AND `_shift_held == true` — labels STILL suppressed (insert-off trumps shift override).
9. **Insert off at startup**: Default state with insert off from the start — no state corruption.
10. **Rapid toggle**: Toggling insert on/off rapidly does not leave state machine in inconsistent state.
11. **Insert off during active hover**: If player is hovering over an NPC and insert goes off — behavior updates correctly next frame.
### Integration tests
12. **Cursor and list agree**: When `insert_active == false`, BOTH cursor's `should_show_interactions()` AND interaction list's `is_showing()` return false. They must be consistent.
13. **GameState.insert_active propagates**: Changes to `GameState.insert_active` are picked up by both systems on next update.
14. **Decision amendment recorded**: The resolved OQ-07 decision is documented in `decisions/perception.md` or `decisions/scope.md` as an amendment to D-056 or D-057.
### Regression markers
- **Existing cursor state tests pass**: All 19 tests in `test_cursor_states.gd` must still pass.
- **Existing interaction list tests pass**: All tests in `test_interaction_list.gd` must still pass, including `test_insert_off_hides_interaction_list`.
- **Sprint suppression still works**: `test_sprint_suppresses_interaction_list` still passes.
- **Weapon mode suppression unchanged**: `test_weapon_mode_suppresses_interactions` still passes.
- **D-045 invariance**: Cursor behavior does NOT change by zone or narrative state.
---
## Test Files
- `client/tests/test_bug_report_ring_buffer.gd` — automated tests for #507 ring buffer
- `client/tests/test_insert_off_behavior.gd` — automated tests for #522 insert-off behavior
## Run Command
```bash
make test-client
```
Or headless via gdUnit4:
```bash
cd client && godot --headless --quit --path . addons/gdUnit4/bin/GdUnitCmdTool.gd \
--testsuites "tests/test_bug_report_ring_buffer.gd,tests/test_insert_off_behavior.gd"
```
## Verification Checklist (fill in after implementations land)
- [ ] All happy path tests pass
- [ ] All edge case tests pass
- [ ] All integration tests pass
- [ ] Regression: `test_cursor_states.gd` — unchanged
- [ ] Regression: `test_interaction_list.gd` — unchanged
- [ ] Regression: `test_anti_tedium.gd` — unchanged
- [ ] `decisions/perception.md` or related file updated with OQ-07 resolution
- [ ] `inputs.jsonl` format verified against `tooling/test-client --replay` manually
- [ ] `seed.txt` present in captured bug report
@@ -0,0 +1,120 @@
# Preliminary Review: #522 OQ-07 Implementation
- **Date**: 2026-02-19
- **Build**: working tree (uncommitted, pending #507 + Tyre arch review)
- **Spec reference**: D-056, D-057
- **Reviewer**: Hoshe (QA Engineer)
- **Status**: PRELIMINARY — full test report follows after task #4 unblocks
---
## Summary
The OQ-07 resolution implements **option (a): cursor shape still changes, verb labels suppressed**.
Implementation is correct. No critical issues. Two minor observations worth tracking.
---
## Files Changed
| File | Change |
|------|--------|
| `client/scripts/autoloads/game_state.gd` | Added `insert_active: bool = true`, snapshot field wiring |
| `client/scripts/rendering/cursor_renderer.gd` | Added `insert_active` var + `set_insert_active()` + modified `should_show_interactions()` |
| `client/ui/interaction_prompt.gd` | Added `_insert_active` var + `set_insert_active()` + modified `_process()` |
| `client/ui/interaction_list.gd` | Already had `_insert_active` + `set_insert_active()` (prior sprint) — no change needed |
| `client/scripts/main.gd` | Propagates `GameState.insert_active` to cursor + list + prompt each snapshot |
| `decisions/perception.md` | OQ-07 resolution amendment appended to D-056 and D-057 |
| `client/tests/test_cursor_states.gd` | 3 new tests for insert-off behavior |
---
## Spec Compliance
### D-056 diegetic test
> "Interaction labels render on z-layer 6 (insert overlay). If the insert is off, labels disappear."
**Status: PASS**
- `cursor_renderer.should_show_interactions()` returns `false` when `insert_active == false`.
- `interaction_prompt._process()` hides prompt when `not _insert_active`.
- `interaction_list.update_from_state()` hides list when `not _insert_active`.
### D-057 diegetic test
> "Labels render on z-layer 6. If insert is off, labels disappear."
**Status: PASS**
- `interaction_list.get_z_layer()` returns `Constants.CANVAS_INSERT`. ✓
- `interaction_prompt` is on `$InsertOverlay/InteractionPrompt` (verified in `main.gd` line 8). ✓
### OQ-07 option (a): cursor shape changes
> "The cursor state machine still fires (shape changes: default → entity hover bracket or X-shape on object hover)"
**Status: PASS**
- `cursor_renderer._detect_hover()` is unchanged — it does not check `insert_active`.
- `cursor_renderer.set_hover_target()` is unchanged — state transitions still fire.
- Confirmed: hovering over an NPC with `insert_active == false` sets state to `EntityHover`. ✓
### Decision amendment
**Status: PASS**
- `decisions/perception.md` has OQ-07 resolution appended to D-056 and D-057 entries. ✓
- Rationale documented: "the body reacts to proximity; the insert reacts to commands."
---
## Observations (Non-blocking)
### OBS-1: Insert state propagated every snapshot, not only on change
**Location**: `main.gd` lines 86-92
**Severity**: Low (v0.1 harmless — insert is always `true`)
`main.gd` propagates `GameState.insert_active` to three nodes on every snapshot, even when the value has not changed. In v0.1 this means `set_insert_active(true)` is called ~10 times per second on cursor, interaction_list, and interaction_prompt.
This is harmless now — each setter is a simple bool write with a conditional. If insert state becomes server-driven in a future sprint (a character without an insert?), adding a change-gate would avoid unnecessary `_hide()` tween calls.
**Recommendation**: Note in a ticket or code comment for v0.2. Not a blocking issue.
### OBS-2: insert_active response timing inconsistency between components
**Location**: `main.gd` inside `if snapshot != null:` block (lines 65-127)
`interaction_prompt._process()` runs every frame and checks `_insert_active` directly. `interaction_list.update_from_state()` and `cursor_renderer.set_insert_active()` only get called when a new snapshot arrives.
In v0.1 (insert always true, no server-driven changes), this is invisible. If a future sprint allows toggling insert state without a new snapshot (e.g., a UI action), `interaction_prompt` would respond immediately while `interaction_list` and `cursor_renderer` would lag by up to one tick.
**Recommendation**: Acceptable for v0.1. For future server-driven insert toggling, consider propagating insert state every frame rather than per-snapshot. Track as technical debt.
### OBS-3: set_insert_active(false) calls _hide() redundantly in next _process()
**Location**: `interaction_prompt.gd` lines 81-84 and 29-32
`set_insert_active(false)` immediately calls `_hide_prompt()` (sets `_is_showing = false`). Then `_process()` fires, sees `not _insert_active`, checks `if _is_showing:` — which is now `false` — and skips the second `_hide_prompt()` call. Correct behavior, slightly redundant guard. No bug.
---
## Test Coverage for #522
### Tests added by Stig (in `test_cursor_states.gd`)
- `test_insert_off_cursor_still_changes_shape` — option (a) confirmation ✓
- `test_insert_off_suppresses_interactions``should_show_interactions()` false ✓
- `test_insert_on_restores_interaction_display` — re-enable ✓
### Tests pre-written by Hoshe (in `test_insert_off_behavior.gd`)
17 tests covering interaction list, cursor, edge cases, cross-system consistency, and regressions.
These will run as part of final task #4 verification.
### Coverage estimate for #522
- Happy path: 95%
- Edge cases: 85% (insert-off + weapon mode, shift override, rapid toggle)
- Integration (cursor + list agree): 80%
- Regression: 100% (all prior cursor and interaction list tests unchanged)
---
## Preliminary Verdict
**APPROVE** pending:
1. Task #3 (Tyre arch review) — no architectural red flags found in preliminary scan
2. Final test run (task #4) once #507 is also complete
No blocking issues identified. Implementation is clean, well-commented, and diegetically consistent.
@@ -0,0 +1,258 @@
# Test Report: Sprint 11 — Combine (#522 + #507)
- **Date**: 2026-02-19
- **Build**: working tree (uncommitted, sprint-11 changes)
- **Branch**: `client`
- **Spec references**: D-056, D-057, OQ-07, D-030, D-020
- **Tickets**: #522 (OQ-07 no-insert interaction), #507 (WRONG button ring buffer)
- **Reviewer**: Hoshe (QA Engineer)
- **Status**: FINAL — APPROVED with 2 bugs found and fixed
---
## Summary
Both #522 and #507 are correctly implemented. All sprint-specific tests pass. Two bugs were
identified and fixed during QA — one in Stig's #507 implementation (`bug_report_dialog.gd`),
one in Hoshe's pre-written test file (`test_bug_report_ring_buffer.gd`). All pre-existing test
failures are unrelated to sprint-11 scope.
---
## Test Execution
### Rust server tests
```
536 tests run: 536 passed, 3 skipped
```
**Result: PASS**
All Rust tests pass. The 3 skipped tests are tagged for explicit invocation only (gen_fixtures).
### GDScript lint
```
No script errors found
```
**Result: PASS** (after fix — see Bug #1 below)
Initial run showed one SCRIPT ERROR in `bug_report_dialog.gd:331` — type inference on a
Variant return value. Fixed during QA. See Bugs section.
### GDScript tests (gdUnit4)
Total: **431 test cases | 5 errors | 13 failures | 0 flaky | 0 skipped**
#### Sprint-11 test suites (all new):
| Suite | Tests | Pass | Fail | Notes |
|-------|-------|------|------|-------|
| `test_insert_off_behavior.gd` | 16 | 16 | 0 | All OQ-07/#522 tests pass |
| `test_bug_report_ring_buffer.gd` | 17 | 17 | 0 | All #507 ring buffer tests pass |
| `test_cursor_states.gd` (3 new) | 19 | 19 | 0 | Including Stig's 3 OQ-07 additions |
#### Pre-existing failures (not from sprint-11):
| Test | Failure | Root cause |
|------|---------|------------|
| `test_e2e_connection.gd > test_send_input_receive_snapshot` | Protocol version mismatch (got 9, expected 8) | Server bumped to v9, `Protocol.PROTOCOL_VERSION` still 8 |
| `test_sprint2_proof.gd > test_proof_player_moves_and_v2_snapshot` | Same mismatch | Pre-existing |
| `test_input_roundtrip.gd > test_movement_roundtrip` | Same mismatch | Pre-existing |
| `test_protocol_bridge.gd > test_fixtures_at_protocol_version_8` | Same mismatch | Pre-existing |
| `test_protocol.gd > test_decode_snapshot_one_npc` | Same mismatch | Pre-existing |
| `test_anti_tedium.gd > test_gauntlet_snapshot_roundtrip_via_apply` (5 assertions) | `gauntlet_mode`/`room_id` not applying | Derived from protocol mismatch — msgpack decode fails silently |
| `test_rendering.gd > test_entity_renderer_facing_indicator_rotation_accuracy` | Precision delta | Pre-existing precision issue, sprint-10 origin |
| `test_client_p3.gd > test_facing_indicator_rotation_matches_input_mapper_angle` | Precision delta | Same |
**Confirmed pre-existing**: None of these failing tests are in files touched by sprint-11.
Server `src/bridge/types.rs` `PROTOCOL_VERSION = 9` predates this sprint (present at HEAD
before any sprint-11 changes). Client `protocol.gd` was not modified in this sprint.
### Rust fixtures
```
Fixtures: UP TO DATE
```
No fixture staleness. `gen_fixtures` test passes; `client/tests/fixtures/` unchanged.
### Content validation
```
47 files: 0 errors, 21 warnings
```
All warnings are pre-existing XREF issues (NPC count mismatch, missing reciprocal relationships).
No content changes in sprint-11 scope. `check-fact-ids` advisory only.
### `cargo fmt --check`
**Status: FAIL (pre-existing, not from sprint-11)**
Formatting failures in server files last modified in sprint 10
(`text_renderer.rs`, `registry.rs`, `types.rs`, test files). None of the affected files were
changed by sprint-11. This is a known technical debt item separate from this sprint's scope.
---
## Bugs Found and Fixed
### Bug #1: GDScript type inference error in `bug_report_dialog.gd:331`
```
## Bug: Variant type inference error — `var seed_val := _get_current_seed()`
- **Severity**: Medium (blocks GDScript lint — prevents `make pre-pr` from passing)
- **Reproduction**: Run `make pre-pr` or godot4 --headless --path client --quit
- **Expected**: GDScript lint passes
- **Actual**: SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant
value, so it will be typed as Variant. (Warning treated as error.)
- **Location**: `client/ui/bug_report_dialog.gd:331`
- **Root cause**: `_get_current_seed()` is declared `-> Variant`. Using `:=` for inference
on a Variant-returning function triggers a strict-mode warning-as-error in Godot 4.
- **Spec reference**: D-030 (testability — lint must pass)
- **Fix applied**: `var seed_val: Variant = _get_current_seed()`
```
### Bug #2: Type inference + `PackedStringArray.filter()` errors in `test_bug_report_ring_buffer.gd`
```
## Bug: Type inference and API errors in pre-written test file
- **Severity**: Medium (test file fails to parse — 17 tests not run)
- **Reproduction**: Run gdUnit4 test suite — test_bug_report_ring_buffer.gd load fails
- **Expected**: All 17 tests parsed and executed
- **Actual**: SCRIPT ERROR: Parse Error: Cannot infer the type of "jsonl" variable...
SCRIPT ERROR: Parse Error: Cannot find member "filter" in base PackedStringArray
- **Root cause**:
1. `var jsonl := dialog._format_inputs_jsonl()` — dynamic method call on Control base type
returns Variant; `:=` can't infer, strict mode rejects.
2. `var lines: PackedStringArray = ...` then `lines.filter()` — PackedStringArray does not
have `filter()` in Godot 4; only `Array` does.
- **Fix applied**:
1. Changed all `:=` assignments to explicit `var x: Type = expr`
2. Converted to `Array(lines).filter(...)` for filter calls (6 occurrences)
```
---
## Spec Compliance: #522 OQ-07
### D-056 diegetic test
> "Interaction labels render on z-layer 6 (insert overlay). If the insert is off, labels disappear."
**PASS** — `test_insert_off_suppresses_should_show_interactions` + 15 other tests confirm.
### D-057 diegetic test
> "Labels render on z-layer 6. If insert is off, labels disappear."
**PASS** — `test_insert_off_hides_interaction_list` + roundtrip restore tests confirm.
### OQ-07 option (a): cursor shape still changes
> "The cursor state machine still fires — shape changes; verb labels suppressed."
**PASS** — `test_insert_off_option_a_cursor_still_transitions`: cursor reaches `EntityHover`
state with insert off. `should_show_interactions()` returns false, preserving verb suppression.
### Decision amendment
**PASS** — `decisions/perception.md` has OQ-07 resolution appended to D-056 and D-057.
---
## Spec Compliance: #507 Ring Buffer
### 60-tick circular buffer
**PASS** — `test_buffer_capacity_is_60`, `test_snapshot_buffer_capacity_is_60`
### Pre-allocation
**PASS** — `test_input_buffer_preallocated_at_ready` (buffer pre-sized in `_ready()`)
### Circular overwrite: oldest evicted
**PASS** — `test_circular_overwrite_evicts_oldest`: after 61 pushes, count is 60
### Circular overwrite: newest preserved
**PASS** — `test_circular_overwrite_keeps_newest_inputs`: 60 non-blank lines after 61 pushes
### JSONL format contract (replay.rs compatibility)
**PASS** — `test_inputs_jsonl_each_line_is_json_array`, `test_inputs_jsonl_idle_tick_is_empty_array`,
`test_inputs_jsonl_has_tick_field`, `test_inputs_jsonl_multiple_actions_per_tick`
Each line is a valid JSON array. Idle tick produces `[]`. Each PlayerInput has `tick` and `action`.
### Snapshot JSONL
**PASS** — `test_snapshots_jsonl_each_line_is_valid_json`: each line is valid JSON Dictionary.
### Seed file
**PASS** — `test_seed_written_on_capture`: `_get_current_seed()` returns non-null Variant.
Returns `"unavailable"` when `GameState.rng_seed == null` (server hasn't sent rng_seed yet).
Expected behavior — server protocol change required for actual seed.
### MVP regression (snapshot.json, render.txt, description.txt)
**PASS** — `test_description_txt_still_contains_room_id`, `test_render_snapshot_text_still_works`,
`test_dialog_is_active_api_unchanged`
### Inter-frame input accumulation (Tyre's arch fix)
**VERIFIED** — `_pending_record_inputs` in `main.gd` accumulates server-bound inputs across
60fps display frames and flushes to `record_tick()` once per 10tps snapshot. All inputs
between snapshot ticks are captured correctly.
---
## Coverage Summary
### #522 (OQ-07 no-insert interaction)
| Category | Tests | Coverage |
|----------|-------|---------|
| Happy path | 4 | 100% |
| Edge cases (weapon + shift + rapid toggle) | 4 | 100% |
| Cross-system consistency (cursor + list agree) | 2 | 100% |
| GameState field (exists, default) | 2 | 100% |
| Z-layer verification | 1 | 100% |
| Regression (sprint + weapon mode) | 2 | 100% |
| Stig's additions in `test_cursor_states.gd` | 3 | 100% |
### #507 (ring buffer upgrade)
| Category | Tests | Coverage |
|----------|-------|---------|
| Buffer capacity | 2 | 100% |
| Pre-allocation | 1 | 100% |
| Push/fill behavior | 2 | 100% |
| Circular overwrite | 3 | 100% |
| JSONL format (replay.rs contract) | 4 | 100% |
| Snapshot JSONL | 1 | 100% |
| Seed file | 1 | 100% |
| MVP regression | 3 | 100% |
---
## Observations (Non-blocking)
### OBS-4: `cargo fmt --check` is a pre-pr blocker requiring separate fix
The `pre-pr` make target fails at step 2 (`cargo fmt --check`) on server files from sprint 10.
This is not a sprint-11 regression. However, it prevents `make pre-pr` from running to
completion. Recommend a `cargo fmt` cleanup commit on the `server` branch before or alongside
this PR.
### OBS-5: Protocol version mismatch (server v9, client v8) affecting 8 tests
`Protocol.PROTOCOL_VERSION` in `client/scripts/protocol/protocol.gd` is still 8, but the
server is at v9. This causes 5+ tests to fail in the integration/e2e test suites. Not in
sprint-11 scope, but worth flagging. Needs a coordinated client+server bump.
---
## Final Verdict
**APPROVED**
- #522 (OQ-07): All 19 tests pass. Spec compliant. Decision amendment documented.
- #507 (Ring buffer): All 17 tests pass. Spec compliant. Replay-format JSONL verified.
- 2 bugs found and fixed during QA (type inference errors).
- Remaining failures are pre-existing, not in sprint-11 scope.
- GDScript lint: clean. Rust tests: 536/536. Fixtures: up to date. Content: 0 errors.
Ready for PR once `cargo fmt` technical debt is addressed (OBS-4).
@@ -575,7 +575,7 @@ The following workshop outputs are ready for formal recording in `decisions/` do
| Velen orbit logistics | Open | Miri | If Sova orbits Velen, freight access to surface needs intra-system wormhole or shuttle service. Flag for next worldbuilding session. |
| 3D render pipeline for 2D sprites | **Resolved** | Tyre, Araminta, Stig | See §10. 3D used only as offline render pipeline — runtime is pure 2D. SubViewport + Camera3D at "the angle" + DirectionalLight3D (neutral). Nano Banana textures UV-mapped onto 3D models, rendered from 4 cardinal directions. |
| Resolution chain | **Resolved** | Tyre, Araminta | 1024x1024 source (archive master) → 256x256 (working, outlines applied at 4-8px) → 64x64 (runtime). Bilinear interpolation both passes. |
| Pipeline test: Era 1 wall texture | **In progress** | Stig, Araminta | Nano Banana texture generated, QA'd (color PASS, flat lighting PASS, tileability PARTIAL — top/bottom seam, acceptable because tiling happens at 3D geometry level). Godot render scene built at `client/tooling/sprite_renderer/`. Render skill created at `.claude/skills/render-sprite/`. Blocked on Godot texture import — needs editor opened once to import the PNG. |
| Pipeline test: Era 1 wall texture | **In progress** | Stig, Araminta | Nano Banana texture generated, QA'd (color PASS, flat lighting PASS, tileability PARTIAL — top/bottom seam, acceptable because tiling happens at 3D geometry level). Godot render scene built at `client/tooling/sprite_renderer/`. Render skill created at `.claude/skills/sprite-gen/`. Blocked on Godot texture import — needs editor opened once to import the PNG. |
| Camera angle: exact value | **Locked** | Lead | "The angle" = ~15-20° from vertical. Rimworld uses orthographic camera with tilt entirely in sprite art. Research confirms no camera projection — the 3D render pipeline Camera3D rotation of -72.5° (from horizontal) produces this. Midpoint of range, tunable by Araminta. |
---
@@ -654,7 +654,7 @@ void fragment() {
| Tileability (3x3 no seams) | **PARTIAL** — Left/right seamless, top/bottom seam visible |
| 64x64 reads as "wall" | **PASS** — Color preserved, reads as surface |
**Render pipeline tooling** built by Stig at `client/tooling/sprite_renderer/`. CLI skill at `.claude/skills/render-sprite/`. First test run blocked on Godot texture import (needs editor opened once to import the PNG into `.godot/imported/`).
**Render pipeline tooling** built by Stig at `client/tooling/sprite_renderer/`. CLI skill at `.claude/skills/sprite-gen/`. First test run blocked on Godot texture import (needs editor opened once to import the PNG into `.godot/imported/`).
**Next step:** Open Godot editor to trigger import, then run render pipeline.
@@ -274,7 +274,7 @@ Safe to create PR.
- Does not run Layer 3 subprocess tests (too slow, nightly-tier)
- Does not run performance benchmarks (machine-dependent, separate target)
- Does not run Gauntlet golden file tests (depends on Gauntlet implementation)
- Does not push or create the PR (that's `make push-pr` or the `/push-pr` skill)
- Does not push or create the PR (that's `make pr-push` or the `/pr-push` skill)
These are separate targets for developers who want deeper verification:
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.10
version: 0.1.11
repository: settled-reach
codename: commonwealth
+1 -1
View File
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.9"
version = "0.1.10"
dependencies = [
"bevy_app",
"bevy_ecs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.10"
version = "0.1.11"
edition = "2021"
[dependencies]
+14 -2
View File
@@ -161,8 +161,18 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
// 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();
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();
@@ -288,6 +298,7 @@ mod tests {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
}
}
@@ -410,6 +421,7 @@ mod tests {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+5
View File
@@ -69,6 +69,11 @@ pub struct ObserverSnapshot {
/// Client shows speaker name + dialogue text in a dialogue box.
#[serde(default)]
pub dialogue_response: Option<DialogueResponseEvent>,
/// Scan events from NPCs with ScanAuthority this tick (#425, D-065).
/// Present when an NPC scanned the player's inventory. Client renders
/// scan indicator on the scanning NPC. Empty when no scans occurred.
#[serde(default)]
pub scan_events: Vec<crate::simulation::contraband::ScanEvent>,
/// 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.
+2 -1
View File
@@ -77,7 +77,8 @@ impl EntityRegistry {
assert!(
target >= self.next_id,
"cannot reserve backwards: next_id={}, target={}",
self.next_id, target
self.next_id,
target
);
self.next_id = target;
}
+2 -2
View File
@@ -140,8 +140,8 @@ impl RelationshipState {
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
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
Self::Hostile => Self::Hostile, // floor: already worst state
}
}
}
+8
View File
@@ -15,6 +15,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
use crate::perception::cognitive_delay::CognitiveDelay;
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
@@ -68,6 +69,7 @@ pub fn compute_observer_snapshot(
Option<&mut SprintAnomalyQueue>,
Option<&CognitiveDelay>,
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
),
With<PlayerCharacter>,
>,
@@ -92,6 +94,7 @@ pub fn compute_observer_snapshot(
mut anomaly_queue_opt,
cognitive_delay_opt,
mut dialogue_response_opt,
mut scan_event_buffer_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -168,6 +171,10 @@ pub fn compute_observer_snapshot(
let current_monologue = monologue_buffer.take();
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
let scan_events = scan_event_buffer_opt
.as_mut()
.map(|buf| buf.take())
.unwrap_or_default();
// Build pending recognitions from CognitiveDelay (#423, D-060)
let pending_recognitions = cognitive_delay_opt
@@ -207,6 +214,7 @@ pub fn compute_observer_snapshot(
pending_recognitions,
dialogue_response,
blocked_entities,
scan_events,
});
}
+1 -4
View File
@@ -2106,10 +2106,7 @@ fn npc_behind_wall_appears_in_blocked_entities() {
"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);
let npc_visible = snapshot.entities.iter().any(|e| e.entity_id == npc_sid.0);
assert!(!npc_visible, "NPC should not be in visible entities");
}
+567
View File
@@ -0,0 +1,567 @@
// Contraband detection system — NPC scan checks carried items + KG (#425, D-065)
//
// NPCs with ScanAuthority check the player's inventory for Contraband items
// when within interaction range. On detection, the NPC's KnowledgeGraph is
// updated with HasContraband fact at KnowsDetails confidence (DirectObservation
// source). A ScanEvent is emitted to the player's ScanEventBuffer for
// client-side rendering via ObserverSnapshot.
//
// System ordering: after perception phase, before snapshot phase.
// Uses StableId references throughout — no raw bevy Entity handles in KG entries.
use bevy_ecs::prelude::*;
use crate::knowledge::types::{
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
};
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::Npc;
use crate::simulation::inventory::CarriedBy;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
/// Scan range for contraband detection (Manhattan distance, same z-level).
/// Matches CLOSE_RANGE from interaction system — NPC must be adjacent.
pub const SCAN_RANGE: u32 = 2;
/// Marker component on item entities that are contraband (D-065).
/// Unlicensed lattice components, medical-grade replacements, Severance tech.
#[derive(Component, Debug, Clone, Copy)]
pub struct Contraband;
/// Component on NPC entities with scan permissions.
/// Only NPCs with this component perform contraband checks.
#[derive(Component, Debug, Clone)]
pub struct ScanAuthority;
/// Wire-format scan event for ObserverSnapshot inclusion.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ScanEvent {
/// StableId of the NPC that performed the scan.
pub scanner_entity_id: u64,
/// Whether contraband was detected.
pub detected_contraband: bool,
}
/// Per-player buffer holding scan events for snapshot inclusion.
/// Cleared each tick by the snapshot builder via `take()`.
#[derive(Component, Debug, Default)]
pub struct ScanEventBuffer {
events: Vec<ScanEvent>,
}
impl ScanEventBuffer {
/// Push a scan event.
pub fn push(&mut self, event: ScanEvent) {
self.events.push(event);
}
/// Drain and return events, leaving the buffer empty.
pub fn take(&mut self) -> Vec<ScanEvent> {
std::mem::take(&mut self.events)
}
}
/// Check for contraband in the player's inventory when scanned by NPC.
///
/// For each NPC with ScanAuthority within SCAN_RANGE of the player:
/// 1. Query player's carried items for Contraband marker
/// 2. If found and NPC doesn't already know: update NPC's KnowledgeGraph
/// with HasContraband fact (KnowsDetails, DirectObservation source)
/// 3. Emit ScanEvent to the player's ScanEventBuffer (always, regardless of
/// detection result or prior knowledge — client renders the scan animation)
///
/// Registered in SimulationPlugin (not NpcPlugin) because it operates on
/// player inventory and writes to the snapshot pipeline. Consistent with
/// process_talk_interaction and other cross-entity systems.
///
/// System ordering: after validate_movement, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
pub fn check_contraband_scan(
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut npc_query: Query<(Entity, &TilePosition, &mut KnowledgeGraph), (With<Npc>, With<ScanAuthority>)>,
mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With<PlayerCharacter>>,
items_query: Query<(&CarriedBy, Option<&Contraband>)>,
) {
let Ok((player_entity, player_pos, mut scan_buffer)) = player_query.single_mut() else {
return;
};
let player_pos = *player_pos;
let Some(player_sid) = registry.to_stable(player_entity) else {
return;
};
// Check if player carries any contraband
let has_contraband = items_query
.iter()
.any(|(carried_by, contraband_opt)| carried_by.0 == player_sid && contraband_opt.is_some());
for (npc_entity, npc_pos, mut npc_kg) in npc_query.iter_mut() {
// Range check: same z-level + within scan range
let Some(distance) = npc_pos.manhattan_distance(&player_pos) else {
continue; // Different z-level
};
if distance > SCAN_RANGE {
continue;
}
let Some(npc_sid) = registry.to_stable(npc_entity) else {
continue;
};
// Build the fact ID for this specific player
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
if has_contraband {
// Only update KG on first detection (idempotent — don't overwrite existing fact)
if !npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails) {
npc_kg.facts.insert(
fact_id,
FactKnowledge {
confidence: KnowledgeConfidence::KnowsDetails,
source: KnowledgeSource::DirectObservation { tick: time.tick },
state: KnowledgeState::Active,
acquired_tick: time.tick,
},
);
// Also ensure the NPC has entity knowledge of the player
npc_kg.observe_entity(player_sid, player_pos, time.tick);
tracing::info!(
npc_id = npc_sid.0,
player_id = player_sid.0,
tick = time.tick,
"Contraband detected: NPC scanned player and found contraband"
);
}
}
// Emit scan event regardless of detection or prior knowledge
// (client renders the scan animation itself)
scan_buffer.push(ScanEvent {
scanner_entity_id: npc_sid.0,
detected_contraband: has_contraband,
});
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn scan_detects_contraband_item() {
let mut world = setup_world();
// Spawn player
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Spawn contraband item carried by player
world.spawn((
CarriedBy(player_sid),
ItemName("Unlicensed Lattice Module".into()),
InventorySlot(0),
Contraband,
));
// Spawn NPC with ScanAuthority adjacent to player
let npc = world
.spawn((
Npc,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ScanAuthority,
))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
// NPC's KG should now contain HasContraband fact
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
assert!(
npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails),
"NPC should know about player's contraband"
);
// NPC should also have entity knowledge of the player
assert!(
npc_kg.knows_entity(&player_sid),
"NPC should have entity knowledge of the player after scan"
);
let _ = npc_sid; // used indirectly
}
#[test]
fn scan_emits_event_to_buffer() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Contraband item
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ScanAuthority,
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert_eq!(events.len(), 1);
assert!(events[0].detected_contraband);
}
#[test]
fn no_contraband_no_kg_update() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Non-contraband item
world.spawn((
CarriedBy(player_sid),
ItemName("Comm Log".into()),
InventorySlot(0),
));
let npc = world
.spawn((
Npc,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ScanAuthority,
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
// NPC's KG should NOT have HasContraband fact
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
assert!(
!npc_kg.knows_fact(&fact_id),
"NPC should not know about contraband when player has none"
);
// But scan event should still fire (NPC still scanned)
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert_eq!(events.len(), 1);
assert!(!events[0].detected_contraband);
}
#[test]
fn out_of_range_no_scan() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
// NPC far away (distance 5, beyond SCAN_RANGE=2)
world.spawn((
Npc,
TilePosition::new(5, 10, 0),
KnowledgeGraph::new(),
ScanAuthority,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert!(events.is_empty(), "out-of-range NPC should not scan");
}
#[test]
fn different_z_level_no_scan() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
// NPC on different z-level
world.spawn((
Npc,
TilePosition::new(5, 6, 1),
KnowledgeGraph::new(),
ScanAuthority,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert!(events.is_empty(), "different z-level should prevent scan");
}
#[test]
fn npc_without_scan_authority_does_not_scan() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
// NPC without ScanAuthority
world.spawn((Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new()));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert!(events.is_empty(), "NPC without ScanAuthority should not scan");
}
#[test]
fn duplicate_scan_skipped_when_already_known() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
// Pre-populate NPC's KG with contraband knowledge
let mut npc_kg = KnowledgeGraph::new();
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
npc_kg.facts.insert(
fact_id.clone(),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsDetails,
source: KnowledgeSource::DirectObservation { tick: 0 },
state: KnowledgeState::Active,
acquired_tick: 0,
},
);
let npc = world
.spawn((
Npc,
TilePosition::new(5, 6, 0),
npc_kg,
ScanAuthority,
))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
// NPC already knew — KG should not be re-written (fact tick stays 0)
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
let fact = npc_kg.facts.get(&fact_id).unwrap();
assert_eq!(fact.acquired_tick, 0, "should not overwrite existing knowledge");
// Scan event should still fire even though NPC already knew
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert_eq!(events.len(), 1, "scan event should emit even for already-known contraband");
assert!(events[0].detected_contraband);
}
#[test]
fn multiple_scan_authority_npcs_each_emit_event() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
KnowledgeGraph::new(),
ScanEventBuffer::default(),
))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
world.spawn((
CarriedBy(player_sid),
ItemName("Lattice Component".into()),
InventorySlot(0),
Contraband,
));
// Two NPCs with ScanAuthority, both in range
let npc1 = world
.spawn((
Npc,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ScanAuthority,
))
.id();
world.resource_mut::<EntityRegistry>().register(npc1);
let npc2 = world
.spawn((
Npc,
TilePosition::new(6, 5, 0),
KnowledgeGraph::new(),
ScanAuthority,
))
.id();
world.resource_mut::<EntityRegistry>().register(npc2);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_contraband_scan);
schedule.run(&mut world);
// Both NPCs should have KG entries
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
let npc1_kg = world.get::<KnowledgeGraph>(npc1).unwrap();
assert!(npc1_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
let npc2_kg = world.get::<KnowledgeGraph>(npc2).unwrap();
assert!(npc2_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
// Both should emit separate scan events
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
let events = buffer.take();
assert_eq!(events.len(), 2, "each ScanAuthority NPC should emit a scan event");
assert!(events.iter().all(|e| e.detected_contraband));
}
#[test]
fn scan_event_buffer_take_drains() {
let mut buffer = ScanEventBuffer::default();
buffer.push(ScanEvent {
scanner_entity_id: 1,
detected_contraband: true,
});
buffer.push(ScanEvent {
scanner_entity_id: 2,
detected_contraband: false,
});
let events = buffer.take();
assert_eq!(events.len(), 2);
let events2 = buffer.take();
assert!(events2.is_empty(), "take should drain the buffer");
}
}
+117 -34
View File
@@ -15,6 +15,8 @@
//! - Writes DialogueResponseBuffer for snapshot inclusion
//! - Uses SimRng for deterministic weighted random selection
use std::collections::BTreeSet;
use bevy_ecs::prelude::*;
use rand::Rng;
@@ -171,18 +173,40 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier
}
}
/// Map RelationshipState to the player's effective TrustTier.
/// Map RelationshipState + KnowledgeConfidence to the player's effective TrustTier.
///
/// v0.1 mapping:
/// - Friendly → Real (relationship depth unlocks deeper trust)
/// - All others → Surface
/// D-075 layered gate: trust requires BOTH relationship depth AND knowledge depth.
/// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
/// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge)
/// - Surface: everything else (baseline, always available)
/// Map relationship + knowledge confidence to trust tier (D-075).
///
/// TODO: TrustTier::Secret is currently unreachable. It should gate on
/// KG confidence (e.g., KnowsDetails+ for a specific secret topic) rather
/// than RelationshipState alone. Tracked for Phase 2 narrative expansion.
pub fn relationship_to_trust(relationship: RelationshipState) -> TrustTier {
/// Trust tier gates which dialogue lines are available. The layered gate
/// requires BOTH sufficient relationship AND sufficient KG confidence:
/// Surface: any relationship, any confidence (baseline)
/// Real: (Friendly|Known) + KnowsOf+ (rapport + substantive knowledge)
/// Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
///
/// KnowledgeConfidence ordering is load-bearing here — the >= comparison
/// relies on the derive(PartialOrd) order: Suspects < KnowsOf < KnowsDetails < Direct.
///
/// Unknown NPCs (no KG entry) default to Suspects, yielding Surface tier.
/// This is correct: you can't have deep dialogue with someone you know nothing about.
pub fn relationship_to_trust(
relationship: RelationshipState,
confidence: crate::knowledge::types::KnowledgeConfidence,
) -> TrustTier {
use crate::knowledge::types::KnowledgeConfidence;
match relationship {
RelationshipState::Friendly => TrustTier::Real,
RelationshipState::Friendly if confidence >= KnowledgeConfidence::KnowsDetails => {
TrustTier::Secret
}
RelationshipState::Friendly | RelationshipState::Known
if confidence >= KnowledgeConfidence::KnowsOf =>
{
TrustTier::Real
}
_ => TrustTier::Surface,
}
}
@@ -373,12 +397,17 @@ pub fn process_talk_interaction(
// Layer 2: Derive active situations from game state
let situations = derive_situations(time.day_phase(), relationship);
// Layer 3: Trust tier from relationship
let trust = relationship_to_trust(relationship);
// Layer 3: Trust tier from relationship + confidence (D-075)
// Default to Suspects for unknown NPCs — no KG entry means no basis for
// deeper dialogue, which correctly yields Surface trust tier.
let confidence = target_stable
.and_then(|sid| observer_kg.confidence_of(&sid))
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
let trust = relationship_to_trust(relationship, confidence);
// Query Layers 1-3: collect candidates across all available access tiers
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
let mut seen_ids: Vec<&str> = Vec::new();
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for access in &access_tiers {
let results = line_pool.0.query_dialogue(
@@ -389,9 +418,8 @@ pub fn process_talk_interaction(
trust,
);
for line in results {
// Deduplicate across access tiers
if !seen_ids.contains(&line.id.as_str()) {
seen_ids.push(&line.id);
// Deduplicate across access tiers (BTreeSet for deterministic iteration)
if seen_ids.insert(&line.id) {
candidates.push(line);
}
}
@@ -565,10 +593,7 @@ 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_02", "The look on their face... they know I know."),
(
"confront_03",
"Cards on the table. Let's see what happens next.",
@@ -600,13 +625,8 @@ pub fn process_confrontation_response(
With<PlayerCharacter>,
>,
) {
let Ok((
player_entity,
confrontation,
mut observer_kg,
mut monologue_buf,
mut monologue_state,
)) = query.single_mut()
let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) =
query.single_mut()
else {
return;
};
@@ -714,26 +734,83 @@ mod tests {
assert_eq!(tiers, vec![AccessTier::Hostile]);
}
// -- Trust tier tests (D-075: layered confidence gate) --------------------
#[test]
fn trust_friendly_is_real() {
fn trust_friendly_knows_details_is_secret() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Friendly),
relationship_to_trust(
RelationshipState::Friendly,
KnowledgeConfidence::KnowsDetails
),
TrustTier::Secret
);
}
#[test]
fn trust_friendly_direct_is_secret() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct),
TrustTier::Secret
);
}
#[test]
fn trust_friendly_knows_of_is_real() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf),
TrustTier::Real
);
}
#[test]
fn trust_others_are_surface() {
fn trust_friendly_suspects_is_surface() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Unknown),
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects),
TrustTier::Surface
);
}
#[test]
fn trust_known_knows_of_is_real() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Known),
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf),
TrustTier::Real
);
}
#[test]
fn trust_known_suspects_is_surface() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects),
TrustTier::Surface
);
}
#[test]
fn trust_unknown_is_always_surface() {
use crate::knowledge::types::KnowledgeConfidence;
assert_eq!(
relationship_to_trust(RelationshipState::PersonOfInterest),
relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct),
TrustTier::Surface
);
}
#[test]
fn trust_poi_is_always_surface() {
use crate::knowledge::types::KnowledgeConfidence;
// PersonOfInterest uses Authority access, not trust depth
assert_eq!(
relationship_to_trust(
RelationshipState::PersonOfInterest,
KnowledgeConfidence::KnowsDetails
),
TrustTier::Surface
);
}
@@ -1576,7 +1653,10 @@ mod tests {
DeviationTrigger::WalkAway,
"Deviation trigger should be WalkAway"
);
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
assert_eq!(
deviation.tick, 42,
"Deviation should record the walk-away tick"
);
}
#[test]
@@ -1659,7 +1739,10 @@ mod tests {
// 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!(
deviation.is_some(),
"NPC should get RoutineDeviation after confrontation"
);
assert_eq!(
deviation.unwrap().trigger,
crate::npc::DeviationTrigger::Confrontation,
+10 -3
View File
@@ -474,7 +474,10 @@ fn handle_confront(
target: target_entity,
});
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
tracing::debug!(
target_id,
"Confront: ConfrontationDelivered marker set on player"
);
}
/// Handle Place verb: remove an item from inventory and place it on the ground
@@ -1850,7 +1853,9 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
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");
@@ -1979,7 +1984,9 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
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");
}
+4
View File
@@ -4,6 +4,7 @@
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod contraband;
pub mod dialogue;
pub mod input;
pub mod interaction;
@@ -38,6 +39,9 @@ impl Plugin for SimulationPlugin {
movement::validate_movement.after(path_follow::follow_paths),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
listening::update_listening_focus.after(movement::validate_movement),
contraband::check_contraband_scan
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+139 -17
View File
@@ -122,12 +122,28 @@ pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom {
name: "dialogue_room",
origin: TilePosition { x: 36, y: 104, z: 0 },
origin: TilePosition {
x: 36,
y: 104,
z: 0,
},
size: (28, 20),
spawn: TilePosition { x: 50, y: 114, z: 0 },
observer: TilePosition { x: 50, y: 114, z: 0 },
spawn: TilePosition {
x: 50,
y: 114,
z: 0,
},
observer: TilePosition {
x: 50,
y: 114,
z: 0,
},
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 50, y: 103, z: 0 }),
reset_plate: Some(TilePosition {
x: 50,
y: 103,
z: 0,
}),
};
pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
@@ -140,6 +156,50 @@ pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }),
};
/// Sprint Gauntlet — Room 8 (28x20)
/// Tests D-055 sprint suppression. Player sprints past a visible NPC;
/// interaction buffer must be empty during sprint, anomaly monologue fires
/// retroactively after sprint ends.
pub const SPRINT_GAUNTLET: GauntletRoom = GauntletRoom {
name: "sprint_gauntlet",
origin: TilePosition { x: 0, y: 2, z: 0 },
size: (28, 20),
spawn: TilePosition { x: 4, y: 10, z: 0 },
observer: TilePosition { x: 4, y: 10, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 14, y: 22, z: 0 }),
};
/// Eavesdrop Alcove — Room 9 (24x16)
/// Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful
/// stance at a corner within audible range of two NPCs in conversation.
pub const EAVESDROP_ALCOVE: GauntletRoom = GauntletRoom {
name: "eavesdrop_alcove",
origin: TilePosition { x: 74, y: 26, z: 0 },
size: (24, 16),
spawn: TilePosition { x: 78, y: 36, z: 0 },
observer: TilePosition { x: 78, y: 36, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 86, y: 42, z: 0 }),
};
/// Confrontation Stage — Room 10 (32x24)
/// Tests D-070 confrontation vulnerability. Peripheral NPC movement during
/// confrontation is suppressed; post-confrontation delayed monologue fires.
pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom {
name: "confrontation_stage",
origin: TilePosition { x: 84, y: 2, z: 0 },
size: (32, 24),
spawn: TilePosition { x: 94, y: 20, z: 0 },
observer: TilePosition { x: 94, y: 20, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition {
x: 100,
y: 26,
z: 0,
}),
};
/// All rooms in canonical spawn order.
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
/// Do not reorder existing entries. Append new rooms at the end.
@@ -152,6 +212,9 @@ pub const ROOMS: &[GauntletRoom] = &[
PAUSE_CHAMBER,
DIALOGUE_ROOM,
CROWD_PLAZA,
SPRINT_GAUNTLET,
EAVESDROP_ALCOVE,
CONFRONTATION_STAGE,
];
/// Look up which room a position falls in.
@@ -190,6 +253,12 @@ 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, 55);
// Sprint 11 rooms — appended after RESET_PLATE_STABLE_IDS per additive-only rule.
pub const SPRINT_GAUNTLET_STABLE_IDS: (u64, u64) = (56, 57);
pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60);
pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62);
/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage).
pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65);
/// Number of actively-spawned entities in the current Gauntlet build.
/// Derived from StableId ranges of all rooms + player + reset plates.
@@ -202,7 +271,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (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;
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize
+ (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize
+ (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize
+ (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
mod tests {
@@ -252,7 +325,11 @@ mod tests {
#[test]
fn room_at_finds_dialogue_room() {
let pos = TilePosition { x: 50, y: 114, z: 0 };
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");
}
@@ -264,22 +341,50 @@ mod tests {
assert_eq!(room.name, "crowd_plaza");
}
#[test]
fn room_at_finds_sprint_gauntlet() {
let pos = TilePosition { x: 4, y: 10, z: 0 };
let room = room_at(&pos).expect("Sprint Gauntlet observer should be in a room");
assert_eq!(room.name, "sprint_gauntlet");
}
#[test]
fn room_at_finds_eavesdrop_alcove() {
let pos = TilePosition { x: 78, y: 36, z: 0 };
let room = room_at(&pos).expect("Eavesdrop Alcove observer should be in a room");
assert_eq!(room.name, "eavesdrop_alcove");
}
#[test]
fn room_at_finds_confrontation_stage() {
let pos = TilePosition { x: 94, y: 20, z: 0 };
let room = room_at(&pos).expect("Confrontation Stage observer should be in a room");
assert_eq!(room.name, "confrontation_stage");
}
#[test]
fn room_at_returns_none_for_corridor() {
// Point inside corridor-E (between Hub and Occlusion)
let pos = TilePosition { x: 66, y: 57, z: 0 };
assert!(room_at(&pos).is_none(), "Corridor should not be in any room");
assert!(
room_at(&pos).is_none(),
"Corridor should not be in any room"
);
}
#[test]
fn room_at_returns_none_for_outside_map() {
let pos = TilePosition { x: 200, y: 200, z: 0 };
let pos = TilePosition {
x: 200,
y: 200,
z: 0,
};
assert!(room_at(&pos).is_none());
}
#[test]
fn all_rooms_in_correct_order() {
assert_eq!(ROOMS.len(), 8);
assert_eq!(ROOMS.len(), 11);
assert_eq!(ROOMS[0].name, "central_hub");
assert_eq!(ROOMS[1].name, "fog_theater");
assert_eq!(ROOMS[2].name, "occlusion_corridor");
@@ -288,6 +393,9 @@ mod tests {
assert_eq!(ROOMS[5].name, "pause_chamber");
assert_eq!(ROOMS[6].name, "dialogue_room");
assert_eq!(ROOMS[7].name, "crowd_plaza");
assert_eq!(ROOMS[8].name, "sprint_gauntlet");
assert_eq!(ROOMS[9].name, "eavesdrop_alcove");
assert_eq!(ROOMS[10].name, "confrontation_stage");
}
#[test]
@@ -297,14 +405,15 @@ mod tests {
if i >= j {
continue;
}
let overlap_x = a.origin.x < b.origin.x + b.size.0
&& a.origin.x + a.size.0 > b.origin.x;
let overlap_y = a.origin.y < b.origin.y + b.size.1
&& a.origin.y + a.size.1 > b.origin.y;
let overlap_x =
a.origin.x < b.origin.x + b.size.0 && a.origin.x + a.size.0 > b.origin.x;
let overlap_y =
a.origin.y < b.origin.y + b.size.1 && a.origin.y + a.size.1 > b.origin.y;
assert!(
!(overlap_x && overlap_y),
"Rooms {} and {} overlap",
a.name, b.name
a.name,
b.name
);
}
}
@@ -317,12 +426,18 @@ mod tests {
assert!(
obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0,
"Observer x={} outside room {} (origin.x={}, width={})",
obs.x, room.name, room.origin.x, room.size.0
obs.x,
room.name,
room.origin.x,
room.size.0
);
assert!(
obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1,
"Observer y={} outside room {} (origin.y={}, height={})",
obs.y, room.name, room.origin.y, room.size.1
obs.y,
room.name,
room.origin.y,
room.size.1
);
}
}
@@ -339,6 +454,10 @@ mod tests {
DIALOGUE_ROOM_STABLE_IDS,
CROWD_PLAZA_STABLE_IDS,
RESET_PLATE_STABLE_IDS,
SPRINT_GAUNTLET_STABLE_IDS,
EAVESDROP_ALCOVE_STABLE_IDS,
CONFRONTATION_STAGE_STABLE_IDS,
SPRINT11_RESET_PLATE_STABLE_IDS,
];
for (i, a) in ranges.iter().enumerate() {
for (j, b) in ranges.iter().enumerate() {
@@ -348,7 +467,10 @@ mod tests {
assert!(
a.1 < b.0 || b.1 < a.0,
"StableId ranges {} and {} overlap: {:?} vs {:?}",
i, j, a, b
i,
j,
a,
b
);
}
}
+223 -21
View File
@@ -16,7 +16,7 @@
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md):
//! StableId ranges (from gestalt-round3.md + Sprint 11):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8
@@ -26,7 +26,11 @@
//! Pause Chamber: 29
//! Dialogue Room: 30-33
//! Crowd Plaza: 34-48
//! Reset plates: 49-55
//! Reset plates (Sprint 1-10 rooms): 49-55
//! Sprint Gauntlet: 56-57
//! Eavesdrop Alcove: 58-60
//! Confrontation Stage: 61-62
//! Reset plates (Sprint 11 rooms): 63-65
#[cfg(feature = "gauntlet")]
pub mod constants;
@@ -40,10 +44,10 @@ use bevy_app::prelude::*;
#[cfg(feature = "gauntlet")]
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
#[cfg(feature = "gauntlet")]
use crate::knowledge::KnowledgeGraph;
#[cfg(feature = "gauntlet")]
use crate::knowledge::types::StableId;
#[cfg(feature = "gauntlet")]
use crate::knowledge::KnowledgeGraph;
#[cfg(feature = "gauntlet")]
use crate::perception::cognitive_delay::CognitiveDelay;
#[cfg(feature = "gauntlet")]
use crate::perception::vision_cone::Facing;
@@ -54,6 +58,7 @@ use crate::simulation::inventory::ItemName;
#[cfg(feature = "gauntlet")]
use crate::simulation::listening::ListeningFocus;
#[cfg(feature = "gauntlet")]
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
#[cfg(feature = "gauntlet")]
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
@@ -93,6 +98,9 @@ pub fn setup_gauntlet(app: &mut App) {
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_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
@@ -102,6 +110,9 @@ pub fn setup_gauntlet(app: &mut App) {
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
// Sprint 11 corridors
carve_corridor(&mut walkability, 12, 22, 6, 18); // corridor-NW: Sprint Gauntlet ↔ Inventory south
carve_corridor(&mut walkability, 62, 30, 12, 6); // corridor-NE: Eavesdrop Alcove ↔ Occlusion north
// 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
@@ -138,6 +149,7 @@ pub fn setup_gauntlet(app: &mut App) {
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
ScanEventBuffer::default(),
CognitiveDelay::default(),
ListeningFocus::new(player_pos),
profile,
@@ -175,13 +187,48 @@ pub fn setup_gauntlet(app: &mut App) {
// 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")),
(
"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
@@ -200,6 +247,53 @@ pub fn setup_gauntlet(app: &mut App) {
.insert(StableEntityId(sid));
}
// --- Sprint Gauntlet (StableId 56-57) ---
rooms::sprint_gauntlet::spawn_entities(app, &mut registry);
// --- Eavesdrop Alcove (StableId 58-60) ---
rooms::eavesdrop_alcove::spawn_entities(app, &mut registry);
// --- Confrontation Stage (StableId 61-62) ---
rooms::confrontation_stage::spawn_entities(app, &mut registry);
// --- Sprint 11 reset plates (StableId 63-65) ---
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
(
"sprint_gauntlet",
constants::SPRINT_GAUNTLET
.reset_plate
.expect("sprint_gauntlet should have a reset_plate"),
),
(
"eavesdrop_alcove",
constants::EAVESDROP_ALCOVE
.reset_plate
.expect("eavesdrop_alcove should have a reset_plate"),
),
(
"confrontation_stage",
constants::CONFRONTATION_STAGE
.reset_plate
.expect("confrontation_stage should have a reset_plate"),
),
];
for &(room_name, pos) in sprint11_reset_plates {
let entity = app
.world_mut()
.spawn((
Interactable,
RoomResetTrigger {
room_name: room_name.to_string(),
},
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
@@ -232,7 +326,9 @@ 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 {
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);
@@ -265,6 +361,35 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Sprint Gauntlet entities (StableId 56-57): sign + NPC, no floor items
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("sprint_gauntlet", entity, *pos, false);
}
}
}
// Eavesdrop Alcove entities (StableId 58-60): NPCs + sign, no floor items
for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("eavesdrop_alcove", entity, *pos, false);
}
}
}
// Confrontation Stage entities (StableId 61-62): NPCs only, no floor items
for id in
constants::CONFRONTATION_STAGE_STABLE_IDS.0..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
{
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("confrontation_stage", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
@@ -374,44 +499,78 @@ mod tests {
// Player at StableId 0
use crate::knowledge::types::StableId;
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
assert!(
registry.to_entity(&StableId(0)).is_some(),
"Player at StableId 0"
);
// Hub signs at 1-4
for id in 1..=4 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Hub sign at StableId {}",
id
);
}
// Fog Theater at 5-8
for id in 5..=8 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Fog Theater at StableId {}",
id
);
}
// Occlusion Corridor at 9-12
for id in 9..=12 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id);
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Occlusion at StableId {}",
id
);
}
// Inventory Warehouse at 13-23
for id in 13..=23 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Inventory at StableId {}",
id
);
}
// Interaction Gallery at 24-28
for id in 24..=28 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", 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");
assert!(
registry.to_entity(&StableId(29)).is_some(),
"Pause Chamber at StableId 29"
);
// Dialogue Room at 30-33
for id in 30..=33 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
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);
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Crowd Plaza at StableId {}",
id
);
}
// Reset plates at 49-55
@@ -422,5 +581,48 @@ mod tests {
id
);
}
// Sprint Gauntlet at 56-57
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint Gauntlet at StableId {}",
id
);
}
// Eavesdrop Alcove at 58-60
for id in
constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Eavesdrop Alcove at StableId {}",
id
);
}
// Confrontation Stage at 61-62
for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0
..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Confrontation Stage at StableId {}",
id
);
}
// Sprint 11 reset plates at 63-65
for id in constants::SPRINT11_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT11_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 11 reset plate at StableId {}",
id
);
}
}
}
+8 -4
View File
@@ -60,7 +60,13 @@ pub struct RoomSnapshots {
impl RoomSnapshots {
/// Record the initial position of an entity in a room.
pub fn record(&mut self, room_name: &str, entity: Entity, position: TilePosition, is_floor_item: bool) {
pub fn record(
&mut self,
room_name: &str,
entity: Entity,
position: TilePosition,
is_floor_item: bool,
) {
self.snapshots
.entry(room_name.to_string())
.or_default()
@@ -196,9 +202,7 @@ mod tests {
#[test]
fn can_reset_after_debounce() {
let mut snapshots = RoomSnapshots::default();
snapshots
.last_reset_tick
.insert("room".to_string(), 100);
snapshots.last_reset_tick.insert("room".to_string(), 100);
assert!(!snapshots.can_reset("room", 105));
assert!(snapshots.can_reset("room", 110));
@@ -0,0 +1,85 @@
//! Confrontation Stage — Room 10 (32x24)
//!
//! Tests D-070 (confrontation as cognitive vulnerability). Player confronts
//! npc_target; peripheral NPC movement occurring during the confrontation
//! should be suppressed by the confrontation audio/perception dip and NOT
//! trigger an anomaly monologue immediately. A delayed post-confrontation
//! monologue fires for the missed peripheral event.
//!
//! Layout: Open stage area. npc_target is positioned north of the player
//! spawn — direct confrontation path. npc_peripheral is placed north-east,
//! within potential anomaly detection range, simulating a passer-by during
//! the confrontation.
//!
//! Observer position: (10, 18) relative = (94, 20) absolute, facing North.
//!
//! Entities (StableId 61-62):
//! npc_target (10, 10) rel = (94, 12) abs — Confrontation target NPC
//! npc_peripheral (24, 6) rel = (108, 8) abs — Peripheral NPC (passer-by)
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 = 84;
const ORIGIN_Y: i32 = 2;
/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description).
#[allow(clippy::type_complexity)]
const NPCS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[
(
10,
10,
WantKind::Power,
7,
-10,
30,
45,
"Confrontation Stage: confrontation target",
),
(
24,
6,
WantKind::Freedom,
4,
15,
5,
70,
"Confrontation Stage: peripheral passer-by",
),
];
/// Spawn Confrontation Stage entities in canonical order (StableId 61-62).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) 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: description.to_string(),
},
Contentment { level: contentment },
ToleranceThreshold {
current_stress: stress,
threshold,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
+41 -11
View File
@@ -31,20 +31,52 @@ const ORIGIN_Y: i32 = 104;
#[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_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_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_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",
"npc_dialogue_d",
14,
14,
WantKind::Knowledge,
3,
10,
5,
70,
"the-terminal",
"observer",
),
];
@@ -65,9 +97,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
intensity,
description: format!("Dialogue Room test NPC: {}", name),
},
Contentment {
level: contentment,
},
Contentment { level: contentment },
ToleranceThreshold {
current_stress: stress,
threshold,
@@ -0,0 +1,103 @@
//! Eavesdrop Alcove — Room 9 (24x16)
//!
//! Tests eavesdrop positioning and ListeningFocus (D-071). Player stands in
//! Careful stance at a corner near two NPCs in conversation. Expected
//! outcomes: World SFX boost observable (D-069), conversation murmur event
//! emitted (D-072), interaction buffer suppressed at eavesdrop distance.
//!
//! Layout: Open alcove. Two NPC speakers are placed north-centre. A Readable
//! corner-position marker sits in the south-west corner — the eavesdrop
//! position. The player observes from the corner with a clear line of sound
//! to both speakers. Manhattan distance from observer to npc_speaker_a is 4
//! (within EAVESDROP_RANGE = 5).
//!
//! Observer position: (4, 10) relative = (78, 36) absolute, facing East.
//!
//! Entities (StableId 58-60):
//! npc_speaker_a (6, 6) rel = (80, 32) abs — First conversation NPC
//! npc_speaker_b (12, 6) rel = (86, 32) abs — Second conversation NPC
//! eavesdrop_corner (4, 10) rel = (78, 36) abs — Readable corner marker
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 74;
const ORIGIN_Y: i32 = 26;
/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description).
#[allow(clippy::type_complexity)]
const SPEAKERS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[
(
6,
6,
WantKind::Connection,
5,
10,
8,
55,
"Eavesdrop Alcove: speaker A",
),
(
12,
6,
WantKind::Knowledge,
6,
5,
12,
60,
"Eavesdrop Alcove: speaker B",
),
];
/// Spawn Eavesdrop Alcove entities in canonical order (StableId 58-60).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
// NPC speakers (StableId 58-59)
for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) in SPEAKERS {
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: description.to_string(),
},
Contentment { level: contentment },
ToleranceThreshold {
current_stress: stress,
threshold,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// Eavesdrop corner marker (StableId 60)
// Readable entity at the corner eavesdrop position. Tests that a Readable
// within interaction range is suppressed when the player is at eavesdrop
// distance from the speakers (interaction buffer suppression at eavesdrop
// distance per D-071 spec).
let corner_pos = TilePosition::new(ORIGIN_X + 4, ORIGIN_Y + 10, 0);
let entity = app
.world_mut()
.spawn((Interactable, ObjectType::Readable, corner_pos))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
@@ -39,10 +39,7 @@ const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
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 entity = app.world_mut().spawn((Interactable, obj_type, pos)).id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
+3
View File
@@ -3,11 +3,14 @@
//! Each room module exports a `spawn_entities()` function that creates
//! entities in canonical order for deterministic StableId assignment.
pub mod confrontation_stage;
pub mod crowd_plaza;
pub mod dialogue_room;
pub mod eavesdrop_alcove;
pub mod fog_theater;
pub mod hub;
pub mod interaction_gallery;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;
pub mod sprint_gauntlet;
@@ -0,0 +1,73 @@
//! Sprint Gauntlet — Room 8 (28x20)
//!
//! Tests D-055 (sprint suppresses interaction buffer) and D-016 (anomaly
//! monologue fires retroactively after sprint ends).
//!
//! Layout: Open east-west corridor. Player sprints from the west end past a
//! visible NPC at the east end. A Readable zone-marker sign at the sprint
//! start position tests that the interaction buffer is empty during sprint.
//! After the sprint ends, the delayed anomaly monologue fires.
//!
//! Observer position: (4, 8) relative = (4, 10) absolute, facing East.
//!
//! Entities (StableId 56-57):
//! sprint_zone_marker (6, 8) rel = (6, 10) abs — Readable zone sign
//! npc_sprint_target (22, 8) rel = (22, 10) abs — Visible NPC during sprint
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 0;
const ORIGIN_Y: i32 = 2;
/// Spawn Sprint Gauntlet entities in canonical order (StableId 56-57).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
// Sprint zone marker sign (StableId 56)
// Placed at the sprint-start position. During sprint the interaction buffer
// must be empty — this Readable entity tests that suppression (D-055).
let sign_pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 8, 0);
let entity = app
.world_mut()
.spawn((Interactable, ObjectType::Readable, sign_pos))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
// Sprint target NPC (StableId 57)
// Visible at the far (east) end of the gauntlet. The player sprints past
// this NPC; if it carries a Contradicted knowledge entry the anomaly
// monologue fires after ANOMALY_DELAY_TICKS (D-055, D-016).
let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 8, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
npc_pos,
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Sprint Gauntlet: visible NPC during sprint".to_string(),
},
Contentment { level: 0 }, // Neutral — test NPC, contentment not load-bearing here
ToleranceThreshold {
current_stress: 10,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
+1
View File
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_unix_socket() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
bridge
+1
View File
@@ -46,6 +46,7 @@ fn snapshot_roundtrip_over_tcp() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
bridge
+3 -3
View File
@@ -142,9 +142,9 @@ fn content_runtime_boot_tick_10_snapshot() {
barrier.wait();
// Server thread must not have panicked
server_handle
.join()
.expect("server thread panicked — content triggered a runtime error during tick processing");
server_handle.join().expect(
"server thread panicked — content triggered a runtime error during tick processing",
);
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
+270 -6
View File
@@ -1,4 +1,4 @@
//! Content scaling test (#500, D-026).
//! Content scaling test (#513, D-026).
//!
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
@@ -6,15 +6,22 @@
//! 1. Tick timing stays within D-026 budget (100ms)
//! 2. Baseline entities still behave identically (deterministic)
//!
//! Sprint 11 adds two new tests (#513 deliverable):
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
//! per-tick budget assertion (every tick < 100ms, not just average).
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
//!
//! Run with: cargo test --test content_scaling -- --nocapture
use bevy_app::prelude::*;
use std::collections::BTreeMap;
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::knowledge::{KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId};
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;
@@ -30,6 +37,24 @@ const MAX_TICK_MS: f64 = 100.0;
/// Extra NPC counts for scaling tiers.
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
const ACTIVE_TIER_NPC_CEILING: usize = 80;
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
const STRESS_TICKS: usize = 100;
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
/// Confrontation Stage: 2 = 34 total.
///
/// Manually maintained — update when rooms are added/changed. Future: derive
/// from StableId ranges in constants.rs to avoid manual sync.
const GAUNTLET_NPC_COUNT: usize = 34;
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
/// Set up a Gauntlet world and return the app.
fn setup_baseline() -> App {
let mut app = App::new();
@@ -106,6 +131,25 @@ fn count_entities(app: &App) -> usize {
registry.len() as usize
}
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
#[cfg(feature = "gauntlet")]
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
let registry = app.world().resource::<EntityRegistry>();
let player_entity = registry
.to_entity(&StableId(0))
.expect("player entity at StableId 0");
match app.world().get::<KnowledgeGraph>(player_entity) {
Some(kg) => kg
.entities
.iter()
.filter(|(id, _)| id.0 <= max_id)
.map(|(id, entry)| (id.0, entry.confidence))
.collect(),
None => BTreeMap::new(),
}
}
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
#[test]
#[cfg(feature = "gauntlet")]
@@ -144,7 +188,10 @@ fn scaling_tick_timing_within_budget() {
results.push((extra_count, total_entities, avg_ms));
}
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_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 {
@@ -215,7 +262,10 @@ fn extra_npcs_dont_affect_baseline_behavior() {
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");
assert_eq!(
baseline_snap.tick, scaled_snap.tick,
"tick count should match"
);
// Same game time
assert_eq!(
@@ -224,8 +274,14 @@ fn extra_npcs_dont_affect_baseline_behavior() {
);
// 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);
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");
@@ -257,3 +313,211 @@ fn extra_npcs_dont_affect_baseline_behavior() {
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
);
}
// =============================================================================
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
// =============================================================================
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
///
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
/// the average) completes within the 100ms D-026 budget.
///
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
/// (same format as tests/perf/baseline.json) so CI can compare against the
/// stored baseline.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_tick_budget() {
let mut app = setup_baseline();
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
let total_entities = count_entities(&app);
// Warm-up: first tick has bevy startup overhead.
app.update();
// Measure STRESS_TICKS, recording each tick individually.
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
for _ in 0..STRESS_TICKS {
let start = Instant::now();
app.update();
per_tick_us.push(start.elapsed().as_micros() as u64);
}
// --- Statistics ---
let min_us = *per_tick_us.iter().min().unwrap();
let max_us = *per_tick_us.iter().max().unwrap();
let sum: u64 = per_tick_us.iter().sum();
let mean_us = sum / per_tick_us.len() as u64;
let mut sorted = per_tick_us.clone();
sorted.sort_unstable();
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
eprintln!(
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
);
eprintln!(
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
);
eprintln!(
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
min_us as f64 / 1000.0,
mean_us as f64 / 1000.0,
p95_us as f64 / 1000.0,
max_us as f64 / 1000.0,
MAX_TICK_MS
);
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
// can be diffed against tests/perf/baseline.json by CI tooling.
println!(
"PERF_RESULT:{}",
serde_json::json!({
"test": "max_npc_pack_tick_budget",
"spec": "D-026",
"tick_timing": {
"warmup_ticks": 1,
"measured_ticks": STRESS_TICKS,
"min_us": min_us,
"max_us": max_us,
"mean_us": mean_us,
"p95_us": p95_us,
},
"entities": {
"total_in_world": total_entities,
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
"extra_npcs": STRESS_EXTRA_NPCS,
},
})
);
// Core assertion: EVERY tick must be within the D-026 100ms budget.
// Average-only checks can mask spikes — verify each individual tick.
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
let over_budget: Vec<(usize, u64)> = per_tick_us
.iter()
.enumerate()
.filter(|(_, &us)| us > budget_us)
.map(|(i, &us)| (i, us))
.collect();
assert!(
over_budget.is_empty(),
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
ACTIVE_TIER_NPC_CEILING,
over_budget.len(),
STRESS_TICKS,
MAX_TICK_MS,
over_budget[0].0,
over_budget[0].1 as f64 / 1000.0
);
}
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
///
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
/// pack for STRESS_TICKS ticks. Asserts:
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
///
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
/// simulation of original entities via LOS, KG, or ECS phase ordering.
/// Spec: #513, D-026, D-010.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_behavioral_regression() {
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
// --- Baseline run: pure Gauntlet, no extra NPCs ---
let mut baseline_app = setup_baseline();
for _ in 0..STRESS_TICKS {
baseline_app.update();
}
let baseline_snapshot = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
let mut stress_app = setup_baseline();
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
for _ in 0..STRESS_TICKS {
stress_app.update();
}
let stress_snapshot = stress_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
let baseline_snap = baseline_snapshot
.expect("baseline Gauntlet should produce a snapshot");
let stress_snap = stress_snapshot
.expect("80-NPC stress run should produce a snapshot");
// Tick index must match (same number of updates).
assert_eq!(
baseline_snap.tick, stress_snap.tick,
"tick count should match between baseline and stress run"
);
// --- 1. Snapshot entity comparison ---
// Collect and sort entity IDs for original Gauntlet entities only.
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
let mut baseline_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
let mut stress_ids: Vec<u64> = stress_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
baseline_ids.sort_unstable();
stress_ids.sort_unstable();
assert_eq!(
baseline_ids, stress_ids,
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
STRESS_TICKS,
baseline_ids.len(),
stress_ids.len(),
STRESS_EXTRA_NPCS
);
// --- 2. Knowledge graph comparison ---
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
// must be identical in both runs. Extra NPCs in the hub may be added to the
// player's KG (higher StableIds), but must not affect original entity entries.
assert_eq!(
baseline_kg, stress_kg,
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
max_gauntlet_id,
STRESS_TICKS,
baseline_kg.len(),
stress_kg.len()
);
eprintln!(
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
baseline_ids.len(),
STRESS_TICKS,
GAUNTLET_NPC_COUNT,
ACTIVE_TIER_NPC_CEILING
);
}
+642
View File
@@ -0,0 +1,642 @@
//! Cross-room transition test scenarios (T1-T8)
//!
//! Sprint 11 (#506) — system-combination tests at room boundaries.
//! Each test exercises a bug class that emerges when two subsystems interact
//! across a coordinate boundary (simulated by player position change).
//!
//! Tests use the ECS world setup pattern with direct system schedule execution
//! — no ad-hoc test harness per sprint requirement.
//!
//! Decision refs:
//! D-055 — sprint suppresses interaction buffer (T1, T8)
//! D-065 — 9-slot inventory, CarriedBy component (T2)
//! D-031 — pause/unpause, TickRate guard (T3)
//! D-041 — KnowledgeGraph persistence across transitions (T4, T5)
//! D-060 — cognitive delay, entity recognition persistence (T5)
//! D-071 — ListeningFocus eavesdrop positioning (T6, T8)
//! D-070 — confrontation as cognitive vulnerability, verb range (T7)
//! D-057 — verb computation, interaction range transitions (T7)
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use settled_reach_server::bridge::types::{MovementStance, VerbKind};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::npc::Npc;
use settled_reach_server::simulation::interaction::{
compute_nearby_interactions, Interactable, NearbyInteractionBuffer, ObjectType,
};
use settled_reach_server::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use settled_reach_server::simulation::listening::{
update_listening_focus, ListeningFocus, EAVESDROP_THRESHOLD, EAVESDROP_THRESHOLD_CAREFUL,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::Stance;
// ---------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------
/// Minimal world with EntityRegistry (no App — single-system schedule tests).
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
fn run_interaction_system(world: &mut World) {
let mut sched = Schedule::default();
sched.add_systems(compute_nearby_interactions);
sched.run(world);
}
fn run_listening_system(world: &mut World) {
let mut sched = Schedule::default();
sched.add_systems(update_listening_focus);
sched.run(world);
}
// ---------------------------------------------------------
// T1: Sprint Exit — buffer clears during sprint, restores on Walk
// D-055, Sprint Gauntlet room
// ---------------------------------------------------------
/// T1 — Sprint Exit.
///
/// Player at (4, 12 absolute) — standalone scenario position south of the
/// Sprint Gauntlet observer (4, 10 per constants.rs). A Readable sign at
/// (6, 12) is within CLOSE_RANGE (distance 2).
///
/// During Walk: sign appears in interaction buffer.
/// During Sprint: buffer is empty (D-055 suppression).
/// After stance returns to Walk: buffer repopulates within one compute cycle.
///
/// This covers the cross-room exit behaviour: player sprinting out of the
/// Sprint Gauntlet loses all interaction context while in sprint.
#[test]
fn t1_sprint_suppresses_buffer_and_restores_on_walk() {
let mut world = setup_world();
// Player at Sprint Gauntlet observer absolute position (ORIGIN_X=0, ORIGIN_Y=2,
// rel observer (4,10) → abs (4,12)), Walk stance.
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(4, 12, 0),
NearbyInteractionBuffer::default(),
Stance(MovementStance::Walk),
))
.id();
// Readable sign at (6, 12) abs — distance 2 from player (CLOSE_RANGE=2).
// Mirrors sprint_gauntlet.rs sign entity (StableId 56).
let sign = world
.spawn((
TilePosition::new(6, 12, 0),
Interactable,
ObjectType::Readable,
))
.id();
world.resource_mut::<EntityRegistry>().register(sign);
// --- Walk: sign appears in buffer ---
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
!interactions.is_empty(),
"T1 Walk: sign at distance 2 must appear in interaction buffer"
);
// --- Sprint: buffer suppressed (D-055) ---
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Sprint;
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
interactions.is_empty(),
"T1 Sprint: interaction buffer must be empty (D-055 sprint suppression)"
);
// --- Walk again: buffer repopulates within one compute cycle ---
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Walk;
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
!interactions.is_empty(),
"T1 Walk after Sprint: interaction buffer must repopulate"
);
}
// ---------------------------------------------------------
// T2: Inventory Carry — CarriedBy survives room transition
// D-065, Inventory Warehouse → Crowd Plaza
// ---------------------------------------------------------
/// T2 — Inventory Interact.
///
/// Player picks up an item (CarriedBy set, TilePosition removed).
/// Player moves to a new coordinate region (simulates room transition).
///
/// Asserts: CarriedBy still references player, item has no TilePosition.
/// The information boundary (D-010 principle 2) holds across coordinates:
/// a carried item is never "in" the new room until explicitly placed.
#[test]
fn t2_carried_item_survives_room_transition() {
let mut world = setup_world();
// Player at Inventory Warehouse observer position (abs 17, 54).
let player = world
.spawn((PlayerCharacter, TilePosition::new(17, 54, 0)))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Item already in inventory (no TilePosition — it has been taken).
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
// Pre-transition invariants.
assert!(
world.get::<TilePosition>(item).is_none(),
"T2 pre: carried item must not have TilePosition"
);
assert_eq!(
world.get::<CarriedBy>(item).unwrap().0,
player_sid,
"T2 pre: CarriedBy must reference player"
);
// Simulate room transition: player moves to Crowd Plaza observer position.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(96, 94, 0);
// Post-transition: item state is unchanged by the player's position change.
assert!(
world.get::<TilePosition>(item).is_none(),
"T2 post: item must still have no TilePosition (still carried)"
);
assert_eq!(
world.get::<CarriedBy>(item).unwrap().0,
player_sid,
"T2 post: CarriedBy must still reference player after movement"
);
assert_eq!(
world.get::<InventorySlot>(item).unwrap().0,
0,
"T2 post: InventorySlot must be unchanged after room transition"
);
}
// ---------------------------------------------------------
// T3: Pause Anywhere — mid-corridor pause discards movement
// D-031
// ---------------------------------------------------------
/// T3 — Pause Anywhere.
///
/// Player at a corridor position between two rooms (corridor-N midpoint
/// between Hub and Fog Theater, ~abs (50, 39)).
/// Pause → movement discarded. Unpause → movement accepted.
///
/// Tests that pause state is position-agnostic: the pause guard fires
/// regardless of whether the player is inside a room or between rooms.
#[test]
fn t3_pause_mid_corridor_discards_movement_and_resumes() {
use bevy_app::prelude::*;
use settled_reach_server::bridge::types::{PlayerAction, PlayerInput};
use settled_reach_server::simulation::input::InputQueue;
use settled_reach_server::simulation::time::{SimulationTime, TickRate};
use settled_reach_server::simulation::SimulationPlugin;
let mut app = App::new();
app.add_plugins(SimulationPlugin);
// 200×200 walkability map covers the full gauntlet coordinate space.
app.insert_resource(WalkabilityMap::new(200, 200, 1));
// Player at corridor-N midpoint (between Hub at y≈46 and Fog Theater at y≈2).
let player = app
.world_mut()
.spawn((PlayerCharacter, TilePosition::new(50, 39, 0)))
.id();
// --- Step 1: Pause (tick 0) ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::Pause,
});
app.update();
assert_eq!(
app.world().resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"T3 step 1: game must be paused"
);
// Paused — advance_tick does not fire; tick stays at 0.
assert_eq!(app.world().resource::<SimulationTime>().tick, 0);
// --- Step 2: MoveNorth while paused (tick 0) — must be discarded ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
app.update();
assert_eq!(
*app.world().get::<TilePosition>(player).unwrap(),
TilePosition::new(50, 39, 0),
"T3 step 2: player position must be unchanged while paused"
);
// --- Step 3: Unpause (tick 0) ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::Unpause,
});
app.update();
assert_eq!(
app.world().resource::<SimulationTime>().tick_rate,
TickRate::Full,
"T3 step 3: game must be running after Unpause"
);
// advance_tick fires for the first time (Full rate): tick 0 → 1.
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
// --- Step 4: MoveNorth after unpause (tick 1) — must be accepted ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
});
app.update();
assert_eq!(
*app.world().get::<TilePosition>(player).unwrap(),
TilePosition::new(50, 38, 0),
"T3 step 4: player must move north (y-1) after unpause"
);
}
// ---------------------------------------------------------
// T4: Knowledge Graph Persistence across room transition
// D-041
// ---------------------------------------------------------
/// T4 — Knowledge State Persistence.
///
/// Player observes NPC in Dialogue Room (adds KG entry at Direct confidence).
/// Player moves to Hub (simulates room transition).
///
/// Asserts: KG entry persists. The KnowledgeGraph component is not cleared
/// or invalidated by a change in player TilePosition.
#[test]
fn t4_knowledge_graph_survives_room_transition() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(50, 114, 0), // Dialogue Room observer position
KnowledgeGraph::new(),
))
.id();
// NPC in Dialogue Room (npc_stranger at abs ~(40, 112)).
let npc = world.spawn(TilePosition::new(40, 112, 0)).id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// Player observes NPC — adds KG entry at Direct confidence.
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0);
assert!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.knows_entity(&npc_sid),
"T4 pre: player must know NPC before room transition"
);
// Simulate room transition: player moves to Hub observer position.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
assert!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.knows_entity(&npc_sid),
"T4 post: KG entry must persist after player moves to Hub"
);
assert_eq!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.entity_count(),
1,
"T4 post: exactly 1 KG entry after room transition"
);
}
// ---------------------------------------------------------
// T5: Entity Knowledge Downgrades on LOS Exit (Fog Carry-Over)
// D-041, D-060
// ---------------------------------------------------------
/// T5 — Fog Carry-Over.
///
/// Player observes NPC in Fog Theater at Direct confidence.
/// Player moves to Hub (NPC now out of LOS). Confidence downgrades
/// from Direct to KnowsDetails.
///
/// Asserts: KG entry persists (entity is remembered, not erased).
/// Server-side "fog carry-over" means previously-seen entities remain in
/// the KG at reduced confidence so the client can render a "last seen"
/// fog state rather than a clean erasure.
///
/// NOTE: Uses direct KG API calls (observe_entity, observe_entity_leaving_los)
/// rather than running the full perception system. This isolates the KG
/// persistence contract from perception scheduling.
#[test]
fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() {
use settled_reach_server::knowledge::types::KnowledgeConfidence;
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(56, 18, 0), // Fog Theater observer position
KnowledgeGraph::new(),
))
.id();
// NPC in Fog Theater (npc_fog_near at abs (38, 16)).
let npc = world.spawn(TilePosition::new(38, 16, 0)).id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// Player observes NPC — Direct confidence.
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0);
assert_eq!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.confidence_of(&npc_sid),
Some(KnowledgeConfidence::Direct),
"T5 pre: NPC must be at Direct confidence while player is in Fog Theater"
);
// Player moves to Hub — NPC is now out of LOS.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
// Observation system downgrades confidence (entity left LOS).
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity_leaving_los(&npc_sid, 1);
let kg = world.get::<KnowledgeGraph>(player).unwrap();
assert!(
kg.knows_entity(&npc_sid),
"T5 post: KG entry must persist after player leaves the room (fog carry-over)"
);
assert_eq!(
kg.confidence_of(&npc_sid),
Some(KnowledgeConfidence::KnowsDetails),
"T5 post: confidence must downgrade from Direct to KnowsDetails on LOS exit"
);
}
// ---------------------------------------------------------
// T6: Eavesdrop Cut on Player Movement
// D-071, Eavesdrop Alcove
// ---------------------------------------------------------
/// T6 — Eavesdrop Cut on Transition.
///
/// Player is stationary at the Eavesdrop Alcove corner position
/// (abs 78, 36) with an active eavesdrop_target. Player moves one step
/// south (leaving the eavesdrop position). Asserts: stationary_ticks resets
/// to 0 and eavesdrop_target clears.
///
/// This prevents eavesdrop state leaking when the player walks out of the
/// Eavesdrop Alcove: the very first movement cuts the focus.
#[test]
fn t6_eavesdrop_cut_on_player_movement() {
let mut world = setup_world();
// NPC speaker A from Eavesdrop Alcove (StableId 58, abs 80, 32).
let speaker_a = world.spawn(TilePosition::new(80, 32, 0)).id();
let speaker_a_sid = world.resource_mut::<EntityRegistry>().register(speaker_a);
// Player at eavesdrop corner position with active eavesdrop focus.
let corner_pos = TilePosition::new(78, 36, 0);
let mut focus = ListeningFocus::new(corner_pos);
focus.stationary_ticks = EAVESDROP_THRESHOLD + 10;
focus.eavesdrop_target = Some(speaker_a_sid);
let player = world
.spawn((
PlayerCharacter,
corner_pos,
focus,
Stance(MovementStance::Careful), // Careful stance for eavesdrop
))
.id();
// Pre-move: eavesdrop is active.
{
let f = world.get::<ListeningFocus>(player).unwrap();
assert!(
f.eavesdrop_target.is_some(),
"T6 pre: eavesdrop_target must be set before movement"
);
assert!(
f.stationary_ticks > EAVESDROP_THRESHOLD,
"T6 pre: stationary_ticks must exceed threshold"
);
}
// Player moves one step south (leaving eavesdrop corner).
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(78, 37, 0);
run_listening_system(&mut world);
// Eavesdrop must be cut.
let f = world.get::<ListeningFocus>(player).unwrap();
assert_eq!(
f.stationary_ticks, 0,
"T6 post: stationary_ticks must reset to 0 on movement"
);
assert!(
f.eavesdrop_target.is_none(),
"T6 post: eavesdrop_target must clear when player leaves eavesdrop position"
);
}
// ---------------------------------------------------------
// T7: Confrontation Interrupt on Room Exit
// D-070, D-057, Confrontation Stage
// ---------------------------------------------------------
/// T7 — Confrontation Interrupt.
///
/// Player at CLOSE_RANGE (distance 2) from NPC target in Confrontation Stage:
/// Talk verb is available (confrontation is possible at this range).
/// Player retreats to observer position (94, 20) — distance 8, beyond MID_RANGE=5:
/// all NPC verbs disappear from the interaction buffer.
///
/// This models the confrontation "interrupt" when the player moves away —
/// the verb set changes, ending the potential confrontation.
#[test]
fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() {
let mut world = setup_world();
// NPC target at Confrontation Stage absolute position (94, 12).
let npc = world
.spawn((Npc, TilePosition::new(94, 12, 0), Interactable))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
// Step 1: Player at (94, 14) — distance 2 from NPC (CLOSE_RANGE=2).
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(94, 14, 0),
NearbyInteractionBuffer::default(),
Stance(MovementStance::Walk),
))
.id();
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert_eq!(
interactions.len(),
1,
"T7 close: NPC at distance 2 must appear in interaction buffer"
);
assert!(
interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk),
"T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)"
);
// Step 2: Player retreats to observer position (94, 20) — distance 8.
// MID_RANGE = 5; distance 8 is fully out of range.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(94, 20, 0);
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
interactions.is_empty(),
"T7 retreat: NPC at distance 8 must not appear in buffer (beyond MID_RANGE=5)"
);
}
// ---------------------------------------------------------
// T8: Sprint Blocks Eavesdrop Accumulation (D-055 + D-071)
// Sprint Gauntlet → Eavesdrop Alcove transition
// ---------------------------------------------------------
/// T8 — Sprint + Eavesdrop Cross-System Interaction.
///
/// Sprint stance (D-055 high-alert) prevents stationary_ticks from
/// accumulating in ListeningFocus (D-071), so eavesdrop cannot activate
/// while the player is sprinting.
///
/// Scenario: Player sprints through Sprint Gauntlet — 50 ticks stationary
/// in Sprint stance → stationary_ticks stays at 0. Player then moves to
/// Eavesdrop Alcove and switches to Careful stance. After
/// EAVESDROP_THRESHOLD_CAREFUL stationary ticks, the threshold is met.
///
/// This catches the cross-system bug: stale sprint state leaking into the
/// eavesdrop counter if the Sprint check in update_listening_focus is absent.
#[test]
fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
let mut world = setup_world();
// Player starts at Sprint Gauntlet observer position with Sprint stance.
let sprint_pos = TilePosition::new(4, 12, 0); // abs (4,12)
let player = world
.spawn((
PlayerCharacter,
sprint_pos,
ListeningFocus::new(sprint_pos),
Stance(MovementStance::Sprint),
))
.id();
// 50 stationary ticks at Sprint — counter must not accumulate.
for _ in 0..50 {
run_listening_system(&mut world);
}
assert_eq!(
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
0,
"T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)"
);
// Transition: player moves to Eavesdrop Alcove, switches to Careful stance.
let alcove_pos = TilePosition::new(78, 36, 0);
*world.get_mut::<TilePosition>(player).unwrap() = alcove_pos;
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Careful;
// One tick to register the movement: system detects position change,
// resets stationary_ticks to 0, and updates last_position to alcove_pos.
run_listening_system(&mut world);
assert_eq!(
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
0,
"T8 transition: movement tick must reset stationary_ticks to 0"
);
// EAVESDROP_THRESHOLD_CAREFUL stationary ticks in Careful stance.
for _ in 0..EAVESDROP_THRESHOLD_CAREFUL {
run_listening_system(&mut world);
}
{
let f = world.get::<ListeningFocus>(player).unwrap();
assert_eq!(
f.stationary_ticks, EAVESDROP_THRESHOLD_CAREFUL,
"T8 careful: stationary_ticks must accumulate cleanly after stance change"
);
assert!(
f.stationary_ticks >= EAVESDROP_THRESHOLD_CAREFUL,
"T8 careful: stationary_ticks must meet Careful eavesdrop threshold"
);
}
// Verify D-071 invariant: Careful threshold is strictly less than normal.
assert!(
EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD,
"T8: Careful threshold must be < normal threshold (D-071 invariant)"
);
}
+2
View File
@@ -36,6 +36,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
}
}
@@ -206,6 +207,7 @@ fn generate_msgpack_fixtures() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
write_fixture(
"snapshot_v2_full",

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