Merge remote-tracking branch 'origin/main' into client

# Conflicts:
#	CLAUDE.md
This commit is contained in:
2026-03-05 08:44:05 +01:00
43 changed files with 10137 additions and 168 deletions
+9 -9
View File
@@ -74,35 +74,35 @@ If the diff is empty, report "No changes to review" and stop.
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
2. Read source files directly from the branch worktree (see below).
2. Read source files directly from the team directory (see below).
For large diffs (>1000 lines of source), provide **source files** rather than
raw diff to reviewers — cleaner context, better reviews.
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
worktrees. Each team branch is checked out at:
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
is checked out in its own directory at:
```
/var/mnt/data/projects/settled-reach/<branch>/
```
For example, the `copy` branch lives at:
For example, the `copy` team directory is at:
```
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
```
**All reviewer agents** (regardless of Bash access) should read source files
from the worktree path using the Read tool. This is more reliable than
from the team directory using the Read tool. This is more reliable than
`git show origin/<branch>:<path>` because:
- All agents have Read access (no Bash dependency)
- Files are always the actual branch checkout (no stale cache)
- No risk of accidentally reading from main's working directory
When constructing reviewer prompts, tell agents to read files from the
worktree path. Example instruction for agents:
team directory. Example instruction for agents:
```
Read the changed files from the branch worktree. The branch is checked
Read the changed files from the team directory. The branch is checked
out at: /var/mnt/data/projects/settled-reach/<branch>/
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
@@ -110,8 +110,8 @@ use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal
```
Also tell agents to read relevant `decisions/*.md` files from the same
worktree (they're identical to main, but using the worktree path keeps
agents grounded in the correct directory).
directory (they're identical to main, but using the team directory path
keeps agents grounded in the correct location).
### 4. Spawn reviewers in parallel
@@ -2,20 +2,24 @@
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**All agents read from worktree paths.** Each branch is checked out at:
**All reviewer agents read from team directories.** Each team branch is
checked out in its own directory at:
`/var/mnt/data/projects/settled-reach/<branch>/`
Tell every reviewer agent to read source files from the worktree using the
Read tool. Include the worktree base path and a list of changed files in
every prompt. Do NOT rely on `git show` or paste file contents — agents
can read directly from the worktree.
Tell every reviewer agent to read source files from the team directory
using the Read tool. Include the directory path and a list of changed
files in every prompt. Do NOT rely on `git show` or paste file contents —
agents can read directly from the directory.
Note: cross-directory reading is only permitted for review agents spawned
from the `main` team. Team agents must stay within their own directory.
## Code reviews (`server`, `client`, `ci`)
**Hoshe (Code Quality)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read source files from the worktree, then review for:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read source files from the team directory, then review for:
- Correctness and bug risks
- Error handling gaps
- Test coverage (are new features tested?)
@@ -25,8 +29,8 @@ can read directly from the worktree.
**Tyre (Architecture)**
- `subagent_type`: `tyre`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
first, then review for:
- Architectural consistency with project decisions
- API/interface design quality
@@ -38,8 +42,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- Formatting consistency (markdown, file naming, frontmatter)
- Broken references or links
- Spelling and grammar
@@ -48,9 +52,9 @@ can read directly from the worktree.
**Paula (Narrative Depth)**
- `subagent_type`: `paula`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, commit log, and
- Prompt: Provide the team directory path, list of changed files, commit log, and
list of relevant `decisions/*.md` files to read. Tell Paula to read all
files from the worktree using the Read tool, then review for:
files from the team directory using the Read tool, then review for:
- Narrative quality and character voice consistency
- Whether dialogue and monologue feel authentic to the characters
- Consequences and stakes — do choices carry weight?
@@ -59,9 +63,9 @@ can read directly from the worktree.
**Miri (World Consistency)**
- `subagent_type`: `miri`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, commit log, and
- Prompt: Provide the team directory path, list of changed files, commit log, and
list of relevant `decisions/*.md` files to read. Tell Miri to read all
files from the worktree using the Read tool, then review for:
files from the team directory using the Read tool, then review for:
- Lore accuracy — do facts match established setting?
- Internal consistency across files
- IP originality — nothing should read as a copy from another franchise
@@ -72,8 +76,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- File format and naming conventions
- Asset organization and directory structure
- Missing or broken references in scene/resource files
@@ -81,7 +85,7 @@ can read directly from the worktree.
**Araminta (Art Direction)**
- `subagent_type`: `araminta`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Araminta to read the style guide and relevant design docs from the
worktree first, then review for:
- Visual consistency with the established style guide
@@ -94,8 +98,8 @@ can read directly from the worktree.
**Hoshe (QA)**
- `subagent_type`: `hoshe`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the worktree, then review for:
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Hoshe to read the changed files from the team directory, then review for:
- File format and naming conventions
- Audio asset organization and directory structure
- Missing or broken references
@@ -103,8 +107,8 @@ can read directly from the worktree.
**Ozzie (Player Experience)**
- `subagent_type`: `ozzie`, `model`: `sonnet`
- Prompt: Provide the worktree path, list of changed files, and commit log.
Tell Ozzie to read all files from the worktree using the Read tool, then
- Prompt: Provide the team directory path, list of changed files, and commit log.
Tell Ozzie to read all files from the team directory using the Read tool, then
review for:
- Emotional impact — does the audio enhance the moment?
- Atmosphere and tone — does it feel like the Commonwealth?
+5 -6
View File
@@ -154,13 +154,12 @@ Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
Each team branch is checked out in its own worktree, and each worktree
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the worktree/git root. Example:
**IMPORTANT — relative paths only:** Each team works in its own directory
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the working directory. Example:
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
paths that navigate outside the git root (`../sibling-worktree/...`).
Agents must stay within the git root they are running in.
paths that navigate outside (`../sibling-dir/...`).
Agents must stay within their team's working directory.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
+5
View File
@@ -290,6 +290,11 @@ Task(
RULES (NON-NEGOTIABLE):
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
within the current working directory. Do NOT navigate to
parent or sibling directories. Do NOT follow .git pointers
to other directories.
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead. No exceptions.
+4
View File
@@ -6,6 +6,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.23] — 2026-03-04
### Added
- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094)
- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577)
@@ -17,6 +19,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580)
- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581)
- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573)
- Sova Transit District tile maps — 5 locations authored: The Terminal (44×28), The Last Shift (34×22), Maintenance Corridors (58×6), Gate Ground (40×34), Gate Gallery (32×10) (#582, #583)
### Fixed
- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584)
@@ -191,6 +194,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
### Changed
- Team boundary framing — replaced worktree-centric language with `$WORKTREE_TEAM` env var identity across CLAUDE.md and skills (sprint-start, sprint-plan, pr-review) to prevent agents from following `.git` pointers across boundaries
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
+14 -6
View File
@@ -27,14 +27,15 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
## Agent Instructions
### Worktree boundaries
### Team boundaries
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
- **All work must remain within the git root** unless explicitly instructed otherwise.
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). The shared `.git` directory means concurrent index access from the same terminal creates `index.lock` collisions. Always run `git add` and `git commit` as **separate sequential Bash calls**.
### Database
@@ -61,6 +62,13 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
### Testing preferences
- **Prefer live Gauntlet testing over mocks.** For visual tests and rendering verification, use the full client/server pipeline (`--test-mode` + `SR_LIVE=1`) instead of TestHarness mocks. The Gauntlet test world produces production-identical data. Mocks can mask rendering bugs by taking different code paths.
- **Gauntlet rooms are immutable.** Never modify existing rooms — new systems get new rooms. This ensures StableId determinism and fixture stability.
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
+8 -2
View File
@@ -5,7 +5,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
@@ -110,6 +110,9 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@# First import may error on theme/font loading before the import scan completes.
@# Run twice: first pass generates imports silently, second pass validates clean.
@$(GODOT) --headless --path client --import --quit 2>/dev/null || true
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -144,6 +147,9 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-gauntlet:
cd server && cargo test --test gen_gauntlet_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
@@ -347,5 +353,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot/* client/reports
rm -rf client/reports
@echo "Clean complete."
+10 -17
View File
@@ -179,9 +179,7 @@ func snapshot() -> Dictionary:
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"tiles": _tiles(),
"visible_tiles": _visible_tiles(),
"visible_positions": _visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
@@ -229,7 +227,7 @@ func _visible_tiles() -> Array:
var vtiles: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
var radius := 5
var room_x := 7
var room_y := 7
var room_w := 8
@@ -241,27 +239,22 @@ func _visible_tiles() -> Array:
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
return vtiles
func _visible_positions() -> Array:
var positions: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
func _get_tile_type(x: int, y: int) -> String:
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(px - radius, px + radius + 1):
for y in range(py - radius, py + radius + 1):
var dist := absf(x - px) + absf(y - py)
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
positions.append({"x": x, "y": y})
return positions
var is_edge := (x == room_x or x == room_x + room_w - 1
or y == room_y or y == room_y + room_h - 1)
if is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
return "door"
return "wall"
return "floor"
# -- Spatial helpers -----------------------------------------------------------
+1
View File
@@ -32,6 +32,7 @@ func _ready() -> void:
# Create seamless noise texture for fog animation
var noise := FastNoiseLite.new()
noise.seed = 42
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.03
var noise_tex := NoiseTexture2D.new()
+21 -59
View File
@@ -1,17 +1,21 @@
shader_type canvas_item;
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
// State 1: Clear (forward cone) — transparent, soft gradient edge (6-8 tile radius)
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
// State 3: Unexplored — solid near-black #12141a
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
//
// Texture pipeline (fog_state.gd): binary 0/255 at 1× tile resolution → CPU Gaussian blur
// (sigma 2.0, 6-8 tile gradient) → Image.resize 4× bilinear upscale → RGBA8 convert.
// Textures arrive here with smooth sub-tile gradients — no GPU-side blur needed.
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
uniform sampler2D visibility_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D exploration_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // D-073: hard zone boundaries
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
@@ -23,40 +27,6 @@ uniform bool debug_exploration = false; // When true, render raw exploration tex
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
float sample_visibility(vec2 uv) {
vec2 t = 2.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
for (float dx = -3.0; dx <= 3.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 8.0);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(visibility_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
// Soft gradient on exploration boundary (5x5, sigma 1.5).
// Prevents hard tile-stepped staircase at explored/unexplored edge.
float sample_exploration(vec2 uv) {
vec2 t = 1.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -2.0; dy <= 2.0; dy += 1.0) {
for (float dx = -2.0; dx <= 2.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 4.5);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(exploration_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
void fragment() {
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
@@ -67,28 +37,15 @@ void fragment() {
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
// Debug mode: render raw exploration texture (bypass fog rendering).
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
// Debug mode: render RAW exploration texture with filter_linear.
// If hardware bilinear works, should show smooth sub-tile gradients.
} else if (debug_exploration) {
float explored_dbg = texture(exploration_tex, tex_uv).r;
if (explored_dbg > 0.9) {
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
} else if (explored_dbg > 0.1) {
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
} else {
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
}
float explored_raw = texture(exploration_tex, tex_uv).r;
COLOR = vec4(vec3(explored_raw), 0.9);
} else {
float vis_raw = texture(visibility_tex, tex_uv).r;
float vis = sample_visibility(tex_uv);
float explored_raw = texture(exploration_tex, tex_uv).r;
float explored = sample_exploration(tex_uv);
// Prevent gradient bleed into never-explored tiles (use raw, unblurred value)
if (explored_raw < 0.01 && vis_raw < 0.01) {
vis = 0.0;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black — information zero
@@ -106,8 +63,13 @@ void fragment() {
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Soft edge between explored and unexplored (blurred to avoid staircase)
float exp_fade = smoothstep(0.0, 0.3, explored);
// Soft edge between explored and unexplored.
// The exploration texture is binary (explored-or-not) blurred over ~12 tiles.
// At the physical tile boundary, explored ≈ 0.5. Squaring the fade keeps
// fog nearly opaque there (97%), hiding tile-aligned content edges.
// Content appears gradually 4-6 tiles inside the explored area.
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper curve: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
@@ -0,0 +1 @@
uid://c1dnlbnxtgqqo
+1
View File
@@ -0,0 +1 @@
uid://bxhgo1e4rvfmi
+78
View File
@@ -24,6 +24,7 @@ var _flow: String = ""
var _interval: float = 3.0
var _list_mode: bool = false
var _config: Dictionary = {}
var _is_live: bool = false
func _init():
@@ -73,6 +74,19 @@ func _run():
return
var main_node = main_scene.instantiate()
# Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim()
_is_live = OS.get_environment("SR_LIVE") == "1"
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var port_env := OS.get_environment("SR_PORT")
if port_env.is_empty():
push_error("visual_capture: SR_LIVE=1 but SR_PORT not set")
quit(1)
return
sim_bridge.server_port = int(port_env)
print("visual_capture: live mode — server port %d" % sim_bridge.server_port)
root.add_child(main_node)
# Wait for NoiseTexture2D async generation
@@ -92,6 +106,36 @@ func _run():
for i in range(settle_count):
await process_frame
# Live mode: wait for server connection and first snapshot
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var game_state := root.get_node("/root/GameState")
print("visual_capture: waiting for server connection...")
var max_frames := 300 # 5 seconds at 60fps
var waited := 0
while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED:
if sim_bridge.state == sim_bridge.ConnectionState.ERROR:
push_error("visual_capture: server connection failed")
quit(1)
return
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: connection timeout after %d frames" % waited)
quit(1)
return
print("visual_capture: connected after %d frames" % waited)
# Wait for first snapshot from server
waited = 0
while game_state.current_tick == 0:
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: no snapshot after %d frames" % waited)
quit(1)
return
print("visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited])
if not _scenario.is_empty():
await _run_scenario(main_node)
elif not _flow.is_empty():
@@ -123,6 +167,40 @@ func _run_scenario(_main_node: Node) -> void:
# Post-tick setup (e.g. zone tint patching)
_scenarios.post_setup(_scenario, root)
# Replay snapshot: inject a real server snapshot through the FULL client pipeline.
# Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd,
# then applies through GameState → FogState → shader — same path as live game.
var replay_path: String = scenario_cfg.get("replay_snapshot", "")
if not replay_path.is_empty():
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
var abs_path := repo_root.path_join(replay_path)
var rf := FileAccess.open(abs_path, FileAccess.READ)
if rf == null:
push_error("visual_capture: cannot open replay snapshot %s" % abs_path)
quit(1)
return
var replay_bytes := rf.get_buffer(rf.get_length())
rf.close()
# Decode through Protocol.decode_snapshot() — same as live IPC receive path.
# This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc.
var replay_data: Variant = Protocol.decode_snapshot(replay_bytes)
if replay_data == null or not replay_data is Dictionary:
push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path)
quit(1)
return
print("visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [
replay_path, replay_bytes.size(),
str(replay_data.get("tick", "?")),
replay_data.get("visible_tiles", []).size()])
var game_state := root.get_node("/root/GameState")
var fog_state := root.get_node("/root/FogState")
game_state.apply_snapshot(replay_data)
fog_state.update_from_state()
# Extra frames for fog uniform propagation
for i in range(4):
await process_frame
# Extra frames for state propagation + viewport texture lag
await process_frame
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://pd1qpgxiodig
+11
View File
@@ -83,6 +83,17 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
sim_bridge.harness.player_pos = Vector2i(11, 9)
sim_bridge.harness.process_input("Interact")
"fog_live_replay", "fog_theater_replay", "fog_boundary_replay":
# Replay real server snapshots via MessagePack → Protocol.decode_snapshot().
# Setup handled by visual_capture.gd (reads replay_snapshot from config).
pass
"fog_live_hub":
# Live server connection — Hub spawn position.
# No setup needed: server starts in --test-mode with Gauntlet,
# player spawns at Hub (50,58). Captures real fog pipeline output.
pass
_:
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
return false
+1
View File
@@ -0,0 +1 @@
uid://dna10a0ln5pd0
+16 -1
View File
@@ -11,6 +11,7 @@ extends Control
## - 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
## - screenshot.png — viewport capture taken before dialog opened
##
## 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
@@ -36,6 +37,7 @@ const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
var _captured_screenshot: Image = null
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
@@ -201,6 +203,8 @@ func _get_filled_snapshot_count() -> int:
func start_capture() -> void:
if _active:
return
# Capture screenshot BEFORE showing the dialog overlay
_captured_screenshot = get_viewport().get_texture().get_image()
_active = true
visible = true
@@ -247,6 +251,8 @@ func _close() -> void:
_line_edit.queue_free()
_line_edit = null
_captured_screenshot = null
# Unpause the simulation
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
@@ -345,7 +351,16 @@ func _save_report(description: String) -> void:
else:
push_error("BugReport: failed to write %s" % seed_path)
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
# 7. screenshot.png — viewport capture taken before dialog opened
if _captured_screenshot:
var screenshot_path := base_path + "/screenshot.png"
var img_err := _captured_screenshot.save_png(screenshot_path)
if img_err == OK:
files_saved += 1
else:
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
+1 -1
View File
@@ -70,7 +70,7 @@ func _cache_geometry() -> void:
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
var content_w := maxf(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
@@ -4,10 +4,13 @@
display_name: "Sova Transit District"
description: >
A 40-year-old prefab-modular-retrofitted freight logistics hub on Station Sova.
Three social sites: The Terminal (logistics hub), The Last Shift (bar),
and maintenance corridors.
Five locations: The Terminal (logistics hub), The Last Shift (bar),
maintenance corridors, gate corridor (ground level), and the observation
gallery (Commission-only, z=2 above the gate concourse).
locations:
- "the-terminal"
- "the-last-shift"
- "maintenance-corridors"
- "gate-ground"
- "gate-gallery"
npc_count: 17
@@ -0,0 +1,48 @@
# Location: Gate Corridor — Observation Gallery
# Source: D-093 gate cluster zone spec
# Zone palette: surface #b8bec4 / fog tint #0a1222
# z=2 (above gate concourse)
canonical_id: "krenn.sova.transit.location.gate-gallery"
display_name: "Observation Gallery"
description: >
Commission-only observation gallery overlooking the gate concourse from
z=2. The gallery rail is a transparent low wall — occupants can see
down to the concourse below, but upward LOS from z=1 is blocked except
at the staircase. Access restricted to Commission personnel.
tile_bounds:
x_min: 0
y_min: 0
x_max: 31
y_max: 9
z: 2
# Legend:
# R = Restricted (Commission-only access)
# W = Wall (solid)
#
# Layout (32 wide x 10 tall):
# Row 0: North wall with staircase entrance (col 1 = R, access point)
# Rows 1-8: Gallery floor (R tiles — restricted access)
# Row 9: South wall (gallery rail — transparent low wall, modeled as W;
# cross-z LOS is handled server-side)
tiles:
- "WRWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
sightlines:
open: true
notes: >
Gallery rail is transparent — full downward LOS to gate concourse (z=1).
Upward LOS from concourse to gallery is blocked except at staircase.
Cross-z LOS behavior handled by server shadowcasting system.
@@ -0,0 +1,83 @@
# Location: Gate Corridor — Ground Level
# Source: D-093 gate cluster zone spec
# Zone palette: surface #b8bec4 / fog tint #0a1222
canonical_id: "krenn.sova.transit.location.gate-ground"
display_name: "Gate Corridor"
description: >
The gate cluster handles all traffic between Station Sova and the horizon
gate network. From north to south: the restricted aperture chamber where
span gate transits occur, freight staging and passenger arrival halls,
customs lanes (freight and pedestrian), and the wide gate concourse
where arrivals disperse into the transit district.
tile_bounds:
x_min: 0
y_min: 0
x_max: 39
y_max: 33
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# R = Restricted (access-tier gated)
#
# Layout (40 wide x 34 tall):
# Row 0: North wall
# Rows 1-4: Aperture chamber (centered 8 wide, R tiles — restricted)
# Row 5: Wall separator
# Rows 6-13: Freight staging (west, 24 tiles) | passenger arrival (east, 13 tiles)
# Row 14: Wall with door gaps (cols 12, 32)
# Rows 15-24: Freight customs (west, 20 tiles, 3 lanes at 6vt + wall stubs)
# | corridor (6 tiles) | ped customs (east, 10 tiles, 3 lanes)
# Row 25: Wall with wide opening to concourse (cols 5-34 open)
# Rows 26-33: Gate concourse (full 38-tile width, 8 rows — public open space)
#
# D-093 zone dimensions: freight customs 20x10, ped customs 12x10 (10 rows each)
# Corridor width: gate concourse 8vt (D-093)
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
- "WWWWWWWWWWWWFWWWWWWWWWWWWWWWWWWWFWWWWWWW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
- "WWWWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
sightlines:
open: false
notes: >
Gate concourse (rows 26-33) is wide open with full sightlines.
Customs lanes have wall stubs creating partial occlusion between
lanes. Freight customs (west, 20 tiles, 3 lanes at 6vt) is wider
than pedestrian customs (east, 10 tiles, 3 lanes). Aperture chamber
is fully walled and restricted. Cross-z LOS from the observation
gallery (z=2) into the concourse is handled server-side.
@@ -1,2 +1,54 @@
# Location: Maintenance Corridors (smuggling spaces)
# canonical_id: krenn.sova.transit.location.maintenance-corridors
# Source: D-093, #313 (Sprint 12 spatial layout)
# Zone palette: surface #4e5054 / fog tint #101214
# z=0 (Era 1, below main structures)
canonical_id: "krenn.sova.transit.location.maintenance-corridors"
display_name: "Maintenance Corridors"
description: >
Era 1 maintenance infrastructure beneath the transit district. A long
transition corridor connects the logistics hub to the bar district.
Restricted storage at the west end is shared with The Terminal above.
A maintenance hatch at the east end provides the cross-z connection
to the main level. Everything reads as mundane maintenance — the ring
operates here because nobody looks twice.
tile_bounds:
x_min: 0
y_min: 0
x_max: 57
y_max: 5
z: 0
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# R = Restricted (access-tier gated)
#
# Layout (58 wide x 6 tall):
# Row 0: North wall
# Row 1: Restricted storage (west, 8 tiles) | wall | corridor (40 tiles) | wall | hatch room (east, 6)
# Row 2: Doors connect rooms (F in wall positions at cols 9 and 50)
# Row 3-4: Same as row 1 (walls between rooms)
# Row 5: South wall
#
# Corridor width: 2vt visible at rows 1-4 (internal height, maintenance standard)
# Transition corridor: 40 tiles (~40m at 1m/vt) between storage and hatch per D-093
# Restricted storage: R tiles for access restriction
# Maintenance hatch: F tiles (cross-z connection is future movement feature)
# Design: mundane maintenance appearance per D-093 G-08
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WRRRRRRRRFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
sightlines:
open: false
notes: >
Long straight corridor (40 tiles) with limited concealment. Storage and
hatch rooms are separated by walls with single-tile doors at row 2.
Mundane appearance per D-093 G-08 — no visually suspicious tile
arrangements.
@@ -1,2 +1,71 @@
# Location: The Last Shift (bar)
# canonical_id: krenn.sova.transit.location.the-last-shift
# Source: D-093, #312 (Sprint 12 spatial layout)
# Zone palette: surface #6b4018 / fog tint #200c04
canonical_id: "krenn.sova.transit.location.the-last-shift"
display_name: "The Last Shift"
description: >
A converted maintenance bay turned bar. The long counter runs along the
west wall with full sightlines across the room. A corner booth in the
northeast sees the bar, card table, entrance, and back room — prime
observation real estate. Scattered tables fill the main floor. A back
room with an alley exit provides the key traversal route for the ring.
tile_bounds:
x_min: 0
y_min: 0
x_max: 33
y_max: 21
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
#
# Layout (34 wide x 22 tall):
# Row 0: North exterior wall
# Rows 1-4: Main floor + corner booth (NE, cols 26-31, walled alcove)
# Row 5: Bar counter starts (cols 1-2 = W fixture)
# Rows 6-10: Bar counter (cols 1-2 W), tables (W stubs), card table (col 25)
# Row 11: Open transition floor
# Rows 12-14: Table clusters (W stubs at cols 8, 16, 24)
# Row 15: News ticker mount (col 31 = W stub)
# Row 16: Open floor
# Row 17: Back room north wall with door (col 22 = F)
# Rows 18-20: Main floor (west) + back room (east, cols 22-32)
# Row 21: South wall with alley exit gap (cols 29-32 = F)
tiles:
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
- "WWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFW"
- "WWWFFFFFFFWFFFFFFFWFFFFFFWFFFFFFFW"
- "WWWFFFFFFFWFFFFFFFWFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFW"
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFWFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWFFFFW"
sightlines:
open: false
notes: >
Corner booth (NE alcove, rows 1-4) has LOS to bar counter, card table
area, main entrance, and back room door. Bar counter along west wall
(cols 1-2, rows 5-10) gives staff full sightlines across the main
floor. Back room is partially occluded by wall at col 21. Back room
door at col 22. Table W-stubs create partial cover but do not fully
block sightlines.
social_site: "bar"
@@ -1,2 +1,77 @@
# Location: The Terminal (logistics hub)
# canonical_id: krenn.sova.transit.location.the-terminal
# Source: D-093, #311 (Sprint 12 spatial layout)
# Zone palette: surface #7a8490 / fog tint #0d1520
canonical_id: "krenn.sova.transit.location.the-terminal"
display_name: "The Terminal"
description: >
Sova's freight logistics hub. Scanner bays filter incoming cargo at the
south entrance; a wide main corridor runs north through the manifest
processing floor. The break room sits in the northeast. The supervisor's
office overlooks the corridor through a large interior window. Restricted
storage in the northwest corner is where the ring hides re-tagged cargo.
tile_bounds:
x_min: 0
y_min: 0
x_max: 43
y_max: 27
z: 1
# Legend:
# F = Floor (walkable) W = Wall (solid, blocks LOS)
# V = Void (outside building) R = Restricted (access-tier gated)
#
# Layout (44 wide x 28 tall):
# Rows 0: North exterior wall
# Rows 1-4: Restricted storage (NW, 11 tiles) + corridor + supervisor office (NE, 9 tiles)
# Row 5: Storage south wall; corridor + office continue
# Rows 6-8: Open corridor; supervisor office east side
# Row 7: ** Supervisor window — col 28 is F (LOS gap into office) **
# Row 9: Supervisor office south wall
# Rows 10-11: Main corridor (full width chokepoint — high sightline value)
# Rows 12-17: Manifest processing (west, 14 tiles) + break room (east, 13 tiles)
# Row 18: Break room south wall; manifest open to corridor
# Rows 19-21: South corridor
# Row 22: Scanner bay north walls with entry gaps
# Rows 23-26: Scanner bay interiors (3 bays)
# Row 27: South exterior wall
tiles:
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWWWWWWWWWWWWWFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWWWWWWWWWWWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFWWWWWWWWWWWWWWWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
- "VVVVVWWWWFFFFFFWWWWFFFFFFFFWWWWFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
sightlines:
open: false
notes: >
Supervisor office window faces main corridor at row 7, col 28 (F gap in
wall line). Player standing in the corridor has direct LOS into the
office interior. This is the primary investigative discovery moment.
social_site: "logistics-hub"
+37 -31
View File
@@ -10,16 +10,23 @@ The fog system was simplified from the original 5-layer spec in Sprint 22 (#569)
| Original D-059 Layer | Sprint 22 Status | Notes |
|----------------------|------------------|-------|
| 1. Clear (forward cone) | Implemented | Soft Gaussian gradient (7x7, sigma 2.0) |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); Gaussian blur provides soft transition |
| 3. Deep fog (previously explored) | Implemented — EXP_EXPLORED | Alpha 0.55-0.70 with zone temperature tint (#563) |
| 1. Clear (forward cone) | Implemented | CPU Gaussian blur (sigma 2.0) → 4× bilinear upscale → RGBA8. Smooth sub-tile gradients. |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); CPU blur + bilinear upscale provides soft transition |
| 3. Deep fog (previously explored) | Simplified — D-015 light fog | Alpha 0.25-0.35 with zone temperature tint. Explored tiles show through light haze. |
| 4. Unexplored + maps app | Deferred | v0.1.2+, requires mapped_tiles in ObserverSnapshot |
| 5. Unexplored (no maps) | Implemented | Solid near-black #12141a |
**Alpha values (Sprint 22, #563):**
- Light fog zone (near cone gradient, vis 0.0-0.3): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.55-0.70, breathing ±0.075 (15-20s)
- Zone temperature tint active in deep fog zone (D-059/D-046/D-077)
**Texture pipeline (fog_state.gd):**
Binary 0/255 data at 1× tile resolution → CPU Gaussian blur (sigma 2.0, radius 4)
`Image.resize()` 4× bilinear upscale → `Image.convert()` RGBA8.
GL compat mode doesn't bilinear-filter R8 textures; RGBA8 at 4× resolves this.
Exploration data is binarized (0/128/255 → 0/255) before blur to avoid a
second gradient at the explored/visible boundary.
**Alpha values (Sprint 22, D-015):**
- Light fog zone (explored, out of cone): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Zone temperature tint active in explored zone (D-059/D-046/D-077)
- Explored/unexplored boundary: squared fade keeps fog opaque at tile content edge
## Overview
@@ -120,30 +127,27 @@ func update_from_state() -> void:
File: `client/shaders/fog.gdshader`
The shader determines fog state per pixel based on the visibility and exploration textures.
Two explored sub-zones are distinguished by the blurred `vis` value (proximity to the forward cone):
The shader reads pre-smoothed RGBA8 textures (CPU blur + 4× bilinear upscale)
and determines fog state per pixel. No GPU-side blur — 2 texture reads per pixel.
```glsl
// Light fog noise — fast cycle (8-10s), subtle ±0.05 breathing
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// Deep fog noise — slow cycle (15-20s), more pronounced ±0.075 breathing
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05; // 0.25-0.35
// Light fog: alpha 0.25-0.35 (near cone gradient)
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
// Deep fog: alpha 0.55-0.70 (far from cone, EXP_EXPLORED)
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
// Blend deep <-> light fog by proximity to cone:
// vis=0 (far from cone) → deep_factor=1 → deep fog color + alpha
// vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
// vis=0.85+ (inside cone) → clarity=1 → transparent (clear)
float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);
// Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
// Clarity ramp: transparent inside cone, light fog at edges and beyond
float clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Explored/unexplored boundary: squared fade hides tile content edges
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
```
**Zone temperature palette (D-046):**
@@ -215,11 +219,13 @@ Per tick (in _process or on snapshot signal):
| Component | Budget | Estimate | Notes |
|-----------|--------|----------|-------|
| Visibility texture upload | 0.1ms | ~0.05ms | 400 pixels via set_pixel() |
| Exploration texture update | 0.1ms | ~0.05ms | Incremental — only changed tiles |
| Fragment shader (1080p) | 0.5ms | ~0.2ms | Single full-screen pass, simple math |
| CPU Gaussian blur (1×, 40×40) | 0.2ms | ~0.1ms | Separable, sigma=2.0, radius=4 (vis: 1 pass, exp: 2 passes) |
| Image.resize C++ (40→160) | 0.1ms | ~0.05ms | INTERPOLATE_BILINEAR, 2× textures |
| Image.convert R8→RGBA8 | 0.1ms | ~0.02ms | GL compat bilinear requires RGBA8 |
| Texture upload (RGBA8 160×160) | 0.2ms | ~0.1ms | 2× textures, ~200KB total |
| Fragment shader (1080p) | 0.2ms | ~0.05ms | 2 texture reads/px (was 98 with GPU blur) |
| Fog entity sprites | 0.2ms | ~0.05ms | 0-15 sprites, trivial draw calls |
| **Total** | **<1ms** | **~0.35ms** | Well within D-059 budget |
| **Total** | **<1ms** | **~0.37ms** | Well within D-059 budget |
## Files to Create
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
# Sprint 24: Signal — Client Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Client work this sprint wires the player-facing signal
> that makes the simulation legible as a story: the character select screen, the triangle
> activation response, and the news ticker. All three must land before #593 (playthrough proof)
> can be filed as done. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #588 | Character archetype selection — client: character select screen before session start | #587 (server: archetype in StartupMessage) |
| #590 | Triangle activation consumer — client: react to triangle_crisis_events (monologue chime + urgent overlay) | #589 (server: tell escalation emitted) |
| #592 | News ticker — client: scrolling ticker HUD element in The Last Shift zone | #591 (server: ticker in snapshot) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests this sprint's work must satisfy), D-039 (wow moments — #1 Arrival: opening monologue; #5 News Ticker Gut-Punch: same ticker, opposite monologue reactions)
- `decisions/architecture.md` — D-020 (ObserverSnapshot is the only data crossing IPC; StartupMessage is the client→server init message; PROTOCOL_VERSION gates wire compatibility), D-042 (UI microcopy in `client/data/ui-strings.yaml` via UIStrings autoload)
- `decisions/content.md` — D-032 (separate monologue pools per character — client does not select the pool; the character string in StartupMessage drives server-side selection), D-074 (audio aesthetic — monologue chime = insert-tech: synthetic, precise, no reverb)
- `decisions/perception.md` — D-067 (recognition chime fires at onset of cognitive delay — `sfx_monologue_chime_urgent.ogg` is the correct asset for triangle activation), D-016 (internal monologue as perception bridge — client only displays what server sends; no client-side monologue logic)
---
## Notes
### #588 — Character archetype select screen
**What exists:** `client/ui/main_menu.gd` (130 lines) — "New Game" button triggers `SessionManager.new_game()` which creates a save directory and seeds `GameState.world_seed`. It then loads `GAME_SCENE` directly, with no character selection step. `client/scripts/autoloads/session_manager.gd``new_game()` returns a `game_id` but does not record which archetype was chosen. `client/scripts/autoloads/game_state.gd` — check whether a `character_archetype` field already exists (likely not — add it). `CharacterArchetype` is a server-side enum; the client needs to record the chosen value as a string (`"smuggler"` or `"detective"`) and include it in `StartupMessage` sent over IPC.
**What to deliver:**
1. `GameState.character_archetype: String` — new field, default `"detective"`. Persisted alongside `world_seed` in the save directory (`user://saves/<game-id>/character.txt` or extend the existing seed file format).
2. Character select scene — insert a step between "New Game" and loading `main.tscn`. This can be a new scene (`client/scenes/character_select.tscn`) or a modal panel within `main_menu.tscn`. Show two options: **Smuggler** and **Detective**. Each option shows the character name, a one-line role description, and a two-line tone description (see below). On selection, set `GameState.character_archetype`, then proceed to `main.tscn`.
**Smuggler card:**
- Name: `Smuggler`
- Role: `Freight logistics worker — Sova Transit`
- Tone: `Insider access. Social camouflage. The ring is your daily life.`
**Detective card:**
- Name: `Detective`
- Role: `Commission investigator — External assignment`
- Tone: `Institutional authority. Analytical lattice. You were sent here.`
These strings belong in `client/data/ui-strings.yaml` (D-042), not hardcoded in GDScript.
3. `Protocol.encode_startup_message()` update — add `character_archetype` to the StartupMessage dict before it is serialized. The server's `StartupMessage` struct now has `pub character_archetype: CharacterArchetype` (#587). Map client string `"smuggler"` → server enum variant `Smuggler`. In MessagePack/GDScript, this is just a string field added to the dict: `{ "world_seed": ..., "character_archetype": "Smuggler" }`.
4. `Protocol.PROTOCOL_VERSION = 19` — bump to match server #587. The client must send the new version on handshake. This is a **hard coordination point** with Dudley — client and server PRs must land together or in the same merge window. A version mismatch will crash the connection on the handshake check.
**UI constraints:** The character select screen must feel intentional, not an afterthought. Two full-width cards, dark background, character name in the sprint's color palette (consistent with main menu). No portraits (art is deferred). Cards are selectable via keyboard (left/right arrows) and mouse click. The selection is confirmed with Enter or a "Begin" button. ESC cancels back to the main menu without creating a save directory.
**Non-obvious gotcha:** `SessionManager.new_game()` currently creates the save directory before any game scene loads. The character select step happens after `new_game()` creates the directory but before the game scene loads. `GameState.character_archetype` must be set before `SimBridge` sends the `StartupMessage` — which happens when `main.tscn` is ready and `SimBridge._ready()` connects to the server. Verify the ordering: `new_game()` → character select panel → user picks archetype → `GameState.character_archetype` set → `main.tscn` loads → `SimBridge._ready()` fires → `StartupMessage` includes archetype.
**Blocked by:** #587 (server must define `character_archetype` field in `StartupMessage` before client serialization is finalized).
---
### #590 — Triangle activation consumer
**What exists:** `client/scripts/snapshot_event_router.gd` — routes snapshot fields to registered handlers. `client/scripts/main.gd` — registers handlers on `_router`. `client/scripts/autoloads/sim_bridge.gd``_on_snapshot_received()` decodes and emits snapshot. `client/scripts/protocol/protocol.gd``decode_snapshot()` returns a dict from the MessagePack bytes. The server snapshot wire type (`ObserverSnapshotWire`) has a `triangle_crisis_events: Vec<TriangleCrisisEventWire>` field (see `server/src/bridge/types.rs` line ~181). This field is present in the MessagePack output. **The client currently ignores it entirely** — there is no decode path for `triangle_crisis_events` in `protocol.gd` and no handler registered in `main.gd`.
**What to deliver:**
1. **Decode `triangle_crisis_events`** in `protocol.gd` `decode_snapshot()`. The field is an array of dicts, each with at minimum `{ "triangle_id": int }`. Add it to the returned snapshot dict as `"triangle_crisis_events": Array`.
2. **Handle activation in `main.gd`** — register a handler that reads `triangle_crisis_events` from the snapshot. When the array is non-empty (at least one event), fire the urgent monologue chime: `AudioManager.play_one_shot(AudioManager.CHIME_RECOGNITION, AudioManager.BUS_UI_SOUNDS)` — wait, check the constant name. The correct asset is `sfx_monologue_chime_urgent.ogg` (D-038, D-067 "sharper variant for contradiction/anomaly"). `AudioManager` has `const CHIME_RECOGNITION := "sfx_monologue_chime"` — add `const CHIME_ACTIVATION := "sfx_monologue_chime_urgent"` if it doesn't exist, then call `AudioManager.play_one_shot(CHIME_ACTIVATION, BUS_UI_SOUNDS)`.
3. **Deduplication** — the triangle activation is a one-shot event (v0.1 fires once per session per D-072/D-089). The client must not fire the chime on every subsequent tick that includes the event in the array. Track activated triangle IDs in a local `Set` in `main.gd`. If `triangle_id` is already in the set, skip. Add to set on first encounter.
4. **No overlay UI** — the monologue chime is the client-side signal. The copy team (#597) authors the proximity monologue lines that fire when the player observes the activated NPC's `tell_state: RoutineDeviation`. The client does not need to render a special overlay or notification — the tell state on the entity and the subsequent proximity monologue are the visible consequence. Keep client reaction to: chime + deduplication tracking only.
**Why no overlay:** D-039 wow moment #2 ("The Character's Eye") is about the monologue noticing something the player didn't. Adding a UI overlay would make it a notification, not a character observation. The feel is: you're wandering near Kael, suddenly you hear the chime — then the next monologue line is your character's internal voice noticing something is off. The server sends the `tell_state: RoutineDeviation` on the NPC entity; the client's entity renderer already renders this as visible entity data that can trigger `observe_npc` monologue.
**Blocked by:** #589 (server must send non-empty `triangle_crisis_events` for client to handle).
---
### #592 — News ticker HUD
**What exists:** `client/ui/hud.gd` and `hud.tscn` — main HUD container. `client/ui/time_display.gd` — insert-style time display already wired via `_router.register_always(time_display.update_from_state)` in `main.gd`. No ticker node or script exists. Server snapshot will carry `current_ticker: Optional<{ id, text, category }>` when player is in the bar zone (#591).
**What to deliver:**
1. `client/ui/news_ticker.gd` + `news_ticker.tscn` — a horizontal scrolling text bar. Design: narrow strip (2432px tall), anchored top of screen or bottom above the dialogue box, full width. Background: dark semi-transparent (`Color(0.05, 0.05, 0.07, 0.75)`). Text: scrolls left at a constant rate (~60px/sec). Text content: the `text` field from `current_ticker`. When `current_ticker` is `null` (player is not in bar zone), the ticker hides itself (`visible = false`).
2. Wire in `main.gd` — add `@onready var news_ticker = $UILayer/NewsTicker` and register: `_router.register_always(news_ticker.update_from_state)`. Implement `news_ticker.update_from_state(snapshot: Dictionary)`: read `snapshot.get("current_ticker")`, update text if changed, show/hide based on null.
3. **Insert overlay compatibility** — the ticker lives on `UILayer` (z-layer 7 per D-049). When the insert overlay is open (`GameState.insert_active = true`), the ticker should NOT be hidden — the news terminal is a real-world object the player can see while their insert is open. Do not call `set_insert_active` on the ticker.
4. **Scrolling behavior** — the headline scrolls in from the right and exits left. When it exits, it restarts from the right with the same text (the server rotates the headline every 200 ticks; client just loops whatever it currently has). No crossfade, no fade-in. Pure marquee.
**UI location:** Confirm with the sprint visual check that the ticker does not occlude the time display (top-right insert) or the monologue display (top-center). If there is a conflict, anchor the ticker at the bottom-center above the dialogue box, 4px margin above.
**Blocked by:** #591 (server must send `current_ticker` field in snapshot before client has real data to render; before that, the ticker renders nothing and stays hidden).
---
## Dependency Chain
```
#587 (server: archetype in StartupMessage)
└→ #588 (character select screen) ← start after #587 is merged
└→ PROTOCOL_VERSION 17→19 bump (coordinate with server)
#589 (server: tell escalation)
└→ #590 (triangle activation consumer) ← start after #589 is merged
#591 (server: ticker in snapshot)
└→ #592 (news ticker HUD) ← start after #591 is merged
#588 + #590 + #592 → #593 (playthrough proof — server ticket)
```
All three client tickets are blocked on their respective server tickets. Start with `protocol.gd` decode additions speculatively (no server data yet — verify against `server/src/bridge/types.rs` for field names), then wire the handlers once server branches are merged to main.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): character archetype select screen" \
--description "body" --base main --head client
```
---
## Sprint Completion (Client Criteria)
1. From main menu, "New Game" → character select screen appears. Both cards render. Keyboard and mouse selection work. ESC cancels without creating a save directory.
2. Selected archetype is persisted in `GameState.character_archetype` and sent in `StartupMessage`. Server receives correct archetype (verify via debug console `status` — it should report the active archetype if Dudley adds it to the status response).
3. `PROTOCOL_VERSION = 19` — client matches server. Connection handshake succeeds.
4. When `triangle_crisis_events` is non-empty in snapshot, `sfx_monologue_chime_urgent.ogg` fires once. Does not re-fire on subsequent ticks.
5. News ticker visible and scrolling in The Last Shift zone. Hidden in all other zones. Text matches the server-sent headline.
6. `make test-client` green on client branch. No regressions in existing test suite.
+147
View File
@@ -0,0 +1,147 @@
# Sprint 24: Signal — Copy Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `copy`
**Agents:** Mellanie (dialogue), Paula (narrative), Gestalt (systems)
> **This is the capstone sprint for v0.1.** The lines authored here are the player's first
> experience of the storyteller doing its job. When the triangle activates and Kael's behavior
> shifts, the character's internal voice is the signal. These lines carry D-039 wow moment #2
> ("The Character's Eye") — the moment the monologue notices something the player didn't.
> Sprint 25 is playtest. Get these lines right.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #597 | Triangle activation — copy: author 35 proximity monologue lines for TriangleActivated (per archetype) | — |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-039 (wow moment #2: "The Character's Eye" — monologue flags something the player didn't notice; urgent chime fires), D-027 (vertical slice criterion: the observe→notice→follow→discover sequence must emerge from systems, not scripts)
- `decisions/content.md` — D-032 (separate monologue pools per character — no shared lines between smuggler and detective), D-035 (tag taxonomy: `trigger: observe_npc`, `situation: [triangle_activated]`, `character`, `prerequisite` fields required), D-090 (PC voice registers — smuggler: contracted, street-cadenced, risk-calculating; detective: analytical, institutional, uncontracted)
- `decisions/content.md` — D-016 (monologue functions: perception bridge, atmosphere, diegetic hint, unreliable narrator — these lines are observation, not exposition), D-034 (THE FRIEND: Kael Davan is smuggler's FRIEND; Sera Venn is detective's FRIEND — these are the primary triangle anchor NPCs)
- `decisions/content.md` — D-024 (tell system — `RoutineDeviation` tell fires when NPC is off schedule; these lines should feel like the character noticing the deviation, not naming the conspiracy)
---
## Open Questions to Resolve Early
None blocking this ticket. The tag schema is fully specified (D-035). The character voice registers are documented (D-090). The NPC identities are confirmed (D-034). Author without waiting for server/client tickets to land — the content files are independent of the implementation.
---
## Notes
### #597 — Triangle activation proximity monologue lines
**What exists:** The monologue pool system (`server/src/simulation/monologue.rs`) selects lines by `trigger`, `character`, `situation`, and `prerequisite`. The `observe_npc` trigger fires when the player is proximate to a specific NPC and has LOS to them. `RoutineDeviation` tell state is the server signal that a triangle anchor NPC is activated. The system can gate monologue lines on situation tags that correspond to game state — `triangle_activated` is a valid situation tag that the server can emit when `TriangleActivatedQueue` is non-empty.
**What to deliver:** 35 monologue lines per character (smuggler and detective), placed in the correct pool files.
**File locations:**
- Smuggler: `content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/smuggler/the-terminal.yaml`
- Detective: `content/campaigns/main/systems/krenn/stations/sova/districts/transit/monologue/detective/the-terminal.yaml`
(Kael and Torek are Terminal NPCs. Sera is also a Terminal presence. These lines fire when the player observes those NPCs post-activation while at The Terminal or maintenance corridors.)
**Tag specification (D-035 required fields):**
```yaml
- id: pc-smuggler_m_s_NNN # NNN = next available index in the smuggler pool
text: "..."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious] # or [anxious] depending on line content
priority: 8 # higher than ambient lines; lower than opening hook (10)
cooldown: 9999 # fires once per activation per session
tags: [triangle-signal, tell-observation]
prerequisite:
npc_in_los: true # only fires when actively observing an NPC
```
**Smuggler lines — Kael Davan (triangle anchor: T1 hub-power)**
The smuggler knows Kael. Kael is a colleague, possibly a friend. The lines must feel like noticing something personal about a familiar person, not flagging a conspiracy suspect. The smuggler's register (D-090): contracted, casual, risk-reading.
Three beats to cover in 35 lines (one line per beat, no doubling):
1. Physical observation — Kael's behavior is off in a specific, observable way (posture, timing, direction).
2. Internal rationalization — the smuggler finds a mundane explanation first. This is deniable.
3. Doubt — the rationalization doesn't quite hold. The smuggler can't name what's wrong. The reader can.
Example tone (do not use as final lines — author fresh):
- Beat 1: "Kael's on the main corridor. He doesn't usually work this route." (Too on-the-nose; soften)
- Beat 2: "Could just be a schedule swap. Voss does that sometimes."
- Beat 3: "...but Kael doesn't swap shifts."
These are tone illustrations, not copy. Paula and Mellanie should author the actual lines.
**Detective lines — Sera Venn and Torek Lintar (triangle anchor: T2 informant-question)**
The detective is external — these are analytical observations about NPCs whose behavior creates a logical anomaly in the investigation pattern. Detective register (D-090): uncontracted, institutional framing, evidence-cataloguing internal voice.
Three beats:
1. Pattern recognition — the detective logs a behavioral deviation as data.
2. Hypothesis formation — what does this deviation imply? (stated as a question, not a conclusion)
3. Procedural next step — the detective's internal instinct is to act, not just observe.
Example tone:
- Beat 1: "Sera avoided eye contact with Torek again. Third occurrence in four observations."
- Beat 2: "If she knows something about his manifest discrepancy, why the silence?"
- Beat 3: "Worth a conversation. But not here."
**What these lines must NOT do:**
- Name the conspiracy directly ("Kael is in the ring" / "Torek is covering evidence")
- Be omniscient — the character observes behavior, not motive
- Be too long — 12 sentences maximum per line; monologue is a flash of interiority
- Repeat vocabulary across lines — each line should use different sensory or cognitive entry
**Verification:** Run the line previewer CLI to confirm schema compliance before committing:
```bash
tooling/db/sqlite-query 'SELECT * FROM ...' # check via line previewer if available
# Or: server/target/debug/line-previewer path/to/the-terminal.yaml
```
Confirm with `make validate-content` that the YAML parses without errors.
---
## Dependency Chain
```
#597 (proximity monologue lines) — no blockers, author immediately
#597 → #593 (playthrough proof — must land before sprint is closed)
```
Start authoring #597 immediately. It is unblocked. The lines need to be in the content files before the playthrough proof (#593) can be run.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(copy): triangle activation proximity monologue lines" \
--description "body" --base main --head copy
```
---
## Sprint Completion (Copy Criteria)
1. 35 smuggler monologue lines in `monologue/smuggler/the-terminal.yaml` with `situation: [triangle_activated]`, correct tag schema (D-035), `cooldown: 9999`, `trigger: observe_npc`.
2. 35 detective monologue lines in `monologue/detective/the-terminal.yaml` with the same schema.
3. `make validate-content` passes — YAML parses, required fields present, no FactId typos.
4. Voice consistency: smuggler lines are contracted, risk-reading; detective lines are analytical, institutional. Paula reviews before PR.
5. Lines do not name the conspiracy — they observe behavior and feel personal to the character.
+120
View File
@@ -0,0 +1,120 @@
# Sprint 24: Signal — Joint / Integration
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Agents:** All implementation agents (Dudley, Tyre, Hoshe, Stig, Mellanie, Paula, Gestalt)
> **This is the capstone sprint for v0.1.** Sprint 23 built the world. Sprint 24 makes it
> legible as a story. Sprint 25 is playtest. Every ticket in this sprint feeds into a single
> proof: a real human can boot the game, pick a character, play for 30 minutes, and experience
> the storyteller doing its job — without scripted interventions or debug shortcuts.
---
## Pre-Sprint Actions
These must happen at sprint start, before implementation tickets begin:
| Action | Owner | Blocks |
|--------|-------|--------|
| Confirm `StartupMessage` wire format with `character_archetype` field | Dudley + Stig sync (day 1) | #588 — client serialization cannot be finalized until server field is published in `bridge/types.rs` |
| Confirm PROTOCOL_VERSION bump: 18 → 19 | Dudley + Stig | #587 server + #588 client must land together — version mismatch crashes handshake |
| Confirm `triangle_crisis_events` decode path for `TriangleCrisisEventWire` field names | Dudley + Stig sync (day 1) | #590 — client `protocol.gd` decode speculates from `bridge/types.rs`; confirm field names before merging |
| Confirm `current_ticker` snapshot field structure (`id`, `text`, `category`) | Dudley + Stig sync (day 1) | #592 — client ticker decode needs field names from server `bridge/types.rs` |
---
## Cross-Team Dependencies
| Dependency | Direction | Notes |
|------------|-----------|-------|
| `character_archetype` in StartupMessage | Server (#587) → Client (#588) | Client cannot send archetype until server defines the field. Stig reads `bridge/types.rs` for the field name before finalizing `Protocol.encode_startup_message()`. |
| PROTOCOL_VERSION 18 → 19 | Server (#587) ↔ Client (#588) | Both must land in the same merge window. A mismatch crashes the handshake. |
| `RoutineDeviation` tell in snapshot | Server (#589) → Client (#590) | Client registers `triangle_crisis_events` handler; server populates it after tell escalation is wired. |
| `current_ticker` in snapshot | Server (#591) → Client (#592) | Client ticker hides when field is absent (null). Client can be merged before server; it simply renders nothing until server sends data. |
| Triangle activation monologue lines | Copy (#597) → Server (#593) | Lines must be in YAML files before playthrough proof run. Copy is unblocked — merge first. |
| Playthrough proof | All tickets → Server (#593) | #593 is the convergence gate. Cannot be filed done until all upstream tickets are merged and CI is green across all branches. |
---
## Housekeeping Done at Sprint Planning
The following tickets were closed during planning as their work was already complete:
| # | Reason |
|---|--------|
| #38 | Client-Server Integration epic — all 11 children done across Sprints 122 |
| #369 | v0.1 Content Scoping Workshop Outputs — all 39 children done |
| #455 | QA Strategy & Test Infrastructure — all 59 children done |
| #575 | LOS boundary bug — fixed by #584/#585 in Sprint 23 |
| #596 | Opening monologue content — fully authored in Sprint 12 (#299 smuggler, #300 detective) |
---
## Sprint Completion Proof
The sprint is done when **all of the following are observable in a live production session** (no `--gauntlet`, no `SR_TEST=1`, no forced debug shortcuts):
1. **Character selection works.** From the main menu, click "New Game." A character select screen appears with two cards: Smuggler and Detective. Both keyboard and mouse selection work. ESC cancels without creating a save. Selecting a character proceeds to the game.
2. **Opening monologue is character-correct.** The first monologue line that fires on session start matches the selected archetype. Smuggler opening: contracted, dock-worker voice, insider framing. Detective opening: analytical, institutional framing, uncontracted. Both fire within the first 5 seconds of game load.
3. **Storyteller activates naturally.** Play as Smuggler. Walk to The Terminal. Observe Kael for ~3 game-minutes (no teleport, no debug shortcut). Eventually (after ~30 game-minutes real or via debug `contaminate`): the urgent monologue chime fires. The next time you approach Kael, a proximity monologue line fires from the `triangle_activated` situation pool.
4. **Tell state is observable.** After activation, stand near Kael. The debug console `npc <kael_entity_id>` reports `tell_state: RoutineDeviation`. The entity renderer colors/indicators for Kael reflect the tell (future animation deferred — tell state emitted is sufficient for v0.1).
5. **News ticker displays in The Last Shift.** Walk to The Last Shift bar. A scrolling text headline is visible in the HUD. Leaving the bar hides it. Returning shows a headline (possibly different, if 200 ticks have elapsed).
6. **CI green across all branches.** `make ci` passes on `main`, `server`, `client`, `copy` branches. No regressions.
---
## Test Plan Alignment (D-030)
Sprint 24 is Phase 3 territory (D-030 Phase 3: CauseChain verification + divergent snapshots):
- **#587/#588:** Protocol round-trip test — `StartupMessage { character_archetype: Smuggler }` survives serialize/deserialize. Phase 1 (data structure).
- **#589:** Unit test — after `TriangleActivatedQueue` is populated, triangle NPCs have `RoutineDeviation`; `derive_tell_state()` returns `RoutineDeviation`. Phase 1.
- **#591:** Unit test — `TickerPool` loads 30 headlines from YAML, rotation advances deterministically with `SimRng`. Phase 1.
- **#595:** Integration test — 1-tick simulation with `StartupMessage { character_archetype: Smuggler }` emits opening monologue line from smuggler pool (not detective pool). Phase 2.
- **#590:** Client test — mock snapshot with `triangle_crisis_events: [{ triangle_id: 1 }]` triggers `CHIME_ACTIVATION` play call once; second snapshot with same triangle_id does not retrigger. Phase 2.
- **#593:** End-to-end integration test — `test_v0_1_integration_playthrough` (server `tests/` directory). Boot server, send Smuggler startup message, advance to contamination via debug command, assert TriangleActivated and RoutineDeviation in golden snapshot. Phase 3.
---
## Open Questions
| ID | Question | Blocks | Action |
|----|----------|--------|--------|
| Q-052 | Storyteller hint delivery channels | — | Resolved at v0.1 scope this sprint. #589 implements channel 1 (behavioral tell escalation), #597 implements channel 7 (proximity monologue). Channels 2 (environmental change) and 4 (overheard NPC conversation) deferred to v0.2. File resolution note in `decisions/questions-content.md` after sprint. |
---
## PR Merge Order
To avoid conflicts on shared files (`bridge/types.rs`, `protocol.gd`, `PROTOCOL_VERSION`):
1. Copy PR (#597) — no code dependencies; merge first. Lines must be in `main` before playthrough proof runs.
2. Server PRs (#589, #591, #594) — no client dependencies; merge in any order. These are independent.
3. **Server PR #587 (StartupMessage + archetype) + Client PR #588 (character select)** — must land together. Both bump `PROTOCOL_VERSION` to 19. Coordinate merge timing.
4. Client PR #590 (triangle consumer) — merge after server #589 is in main.
5. Client PR #592 (ticker HUD) — can merge before server #591 (renders nothing when field absent); merge after for clean integration test.
6. Server PR #595 (opening monologue gate) — merge after server #587 is in main.
7. Server PR #593 (playthrough proof) — last to merge. Requires all upstream PRs green.
**Critical:** #587 and #588 share the PROTOCOL_VERSION bump. Do not merge one without the other.
---
## v0.1 Readiness After This Sprint
After Sprint 24 ships, the vertical slice (D-027) satisfies:
| D-027 Criterion | Sprint 24 Delivery | Status |
|----------------|-------------------|--------|
| 30 minutes of daily-life breathing room before contamination | Contamination delay (Sprint 22 #254) + storyteller lifecycle (Sprint 23 #572) | Done — Sprint 22/23 |
| Both playthroughs feel fundamentally different | Character archetype select (#587/#588) + archetype-gated monologue (#595) + character voice (#597) | Done — this sprint |
| Player names an NPC they felt conflicted about | Kael/Sera content (Sprint 1222) + tell escalation (#589) + proximity monologue (#597) | Done — this sprint |
| Observe→notice→follow→discover emerges from systems | Tell escalation (#589) + proximity monologue (#597) + existing dialogue/knowledge graph | Done — this sprint |
Sprint 25 will address wow moments #3 (THE FRIEND's Contradiction) and #6 (The Quiet Moment) — the two that require deeper playtest-driven tuning. The core loop is complete after Sprint 24.
+199
View File
@@ -0,0 +1,199 @@
# Sprint 24: Signal — Server Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Everything server-side must converge on a production
> playthrough: correct character spawned, opening monologue fired, storyteller active, triangle
> escalation observable. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #587 | Character archetype — server: accept archetype in StartupMessage, spawn correct PC | — |
| #589 | Triangle activation consumer — server: behavioral tell escalation on TriangleActivated | — |
| #591 | News ticker — server: load ticker YAML, emit current headline in snapshot | — |
| #595 | Opening monologue trigger — server: emit opening lines at session start based on archetype | #587 |
| #593 | v0.1 playthrough proof: full session from main menu through storyteller activation | #587, #589, #591, #595 (+ client #588, #590, #592 + copy #597) |
| #594 | Tile data model design — produce D-record for extensible tile properties | — (design only, unblocks post-v0.1) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests the playthrough must satisfy), D-039 (6 wow moments — opening arrival, character's eye, FRIEND contradiction, divergence reveal, news ticker gut-punch, quiet moment)
- `decisions/content.md` — D-032 (separate monologue pools: `character` tag is a hard partition, not a filter), D-035 (tag taxonomy: `trigger`, `character`, `prerequisite` fields on monologue lines), D-023 (three-tier content model — storyteller activates Tier 1 modules based on engagement)
- `decisions/content.md` — D-024 (NPC 10-axis model — tell system axis 9; tells flow from simulation state not authored), D-036 (Sova Transit District — The Terminal, The Last Shift, news ticker content at `ticker/the-last-shift.yaml`)
- `decisions/architecture.md` — D-020 (StartupMessage is the only initialization crossing IPC; PROTOCOL_VERSION gates wire compatibility), D-041 (knowledge graph data model — KnowledgeConfidence confidence levels), D-010 principle 3 (no player identity baked into game loop — archetype is a configuration, not a special case)
---
## Open Questions to Resolve Early
- **Q-052: Storyteller hint delivery channels** — #589 implements channels 1 (behavioral tell escalation) and 7 (proximity monologue, via copy #597). Environmental change (channel 2) and overheard NPC conversation (channel 4) are deferred to Sprint 25. This sprint resolves Q-052 at v0.1 scope. No design discussion needed — the channel inventory is decided; implementation scope is constrained to what's achievable this sprint.
---
## Notes
### #587 — Character archetype in StartupMessage
**What exists:** `StartupMessage` in `server/src/bridge/types.rs` has one field: `pub world_seed: u64`. The production startup path initializes all NPCs and spawns the player entity without any archetype selection — the player entity carries `CharacterArchetype::Detective` by default (see `server/src/simulation/monologue.rs` line ~174: `character: "detective".to_string()`). `CharacterArchetype` enum is already defined in `bridge/types.rs` with `Smuggler` and `Detective` variants (used by phase-2 verb filter in `server/src/perception/observer/mod.rs`).
**What to deliver:**
1. Add `pub character_archetype: CharacterArchetype` to `StartupMessage`. Default to `Detective` if absent during deserialization (backward-compatible via `#[serde(default)]`).
2. In the production startup path (`server/src/main.rs` or `server/src/content/spawn.rs`), read `startup_msg.character_archetype` and insert the correct `CharacterArchetype` component on the player entity. The player entity is currently spawned without an archetype component — insert it here.
3. In `server/src/simulation/monologue.rs`, read the `CharacterArchetype` component from the player entity at `MonologueState` initialization (or on first tick) and set `MonologueState.character` from it: `"smuggler"` or `"detective"`. This is the string that gates all monologue pool selection.
4. Bump `PROTOCOL_VERSION` to 19. The new field in `StartupMessage` is a breaking change — old clients send a message that omits `character_archetype`, new server will default it correctly, but old servers receiving the new format will fail. Coordinate with Stig (#588) on timing.
**Gotcha:** `CharacterArchetype` already serializes via serde. The existing `impl From<CharacterArchetype> for ObserverSnapshot` path in bridge tests validates round-trips — make sure the new `StartupMessage` test also covers the default case (`Smuggler` serializes and round-trips; missing field deserializes as `Detective`).
**Unblocks:** #595 (opening monologue), #588 (client select screen can now send the archetype).
---
### #589 — Triangle activation consumer: behavioral tell escalation
**What exists:** `server/src/npc/tell_state.rs``TellCategory` enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation) and `derive_tell_state()` function. Tell state is derived from NPC axis values each tick and emitted in `ObserverSnapshot.entities[].tell_state`. `RoutineDeviation` component exists. The storyteller emits `TriangleActivated { triangle_id, npc_entity }` into `TriangleActivatedQueue`.
**What to deliver:** A system `escalate_tells_on_activation()` that:
1. Drains `TriangleActivatedQueue` each tick (non-destructively — queue must still be readable by other consumers; use `events()` pattern or check if existing drain is appropriate).
2. For each `TriangleActivatedEvent`, find the anchor NPC entity (`event.npc_entity`) and the 1-2 NPCs who are in the same triangle (via `TriangleState` query). These are the triangle's NPCs.
3. Insert a `RoutineDeviation` component on all triangle NPCs. `RoutineDeviation` is the strongest tell category per `derive_tell_state()` priority order — it overrides Nervous/Angry/Guarded. This makes the triangle NPCs immediately observable as anomalous.
4. The `RoutineDeviation` component should carry a `expires_at_tick: u64` field (if not already present) so it can be removed after a configurable window (suggest `TELL_ESCALATION_DURATION_TICKS = 300` = 30 game-minutes). Add a system to remove expired `RoutineDeviation` components.
**Why this is the right channel:** D-024 axis 9 (tell system) is a simulation output, not authored content. The `RoutineDeviation` tell fires when the NPC is off their usual schedule — which is exactly true post-activation (the triangle is hot). No new content required. Client sees it as `tell_state: RoutineDeviation` on the visible entity. Copy (#597) authors the monologue lines the client fires when the player observes this tell.
**Integration point:** `TriangleActivatedQueue` is in `server/src/storyteller/mod.rs`. The new system should live in `server/src/storyteller/` or `server/src/simulation/pressure.rs` — either is appropriate. Register in `StorytellerPlugin.build()`.
**Test:** `cargo test` — verify that after `TriangleActivatedQueue` is populated with a test event, triangle NPCs have `RoutineDeviation` inserted and `derive_tell_state()` returns `RoutineDeviation` for them.
---
### #591 — News ticker: server-side
**What exists:** `content/campaigns/main/systems/krenn/stations/sova/districts/transit/ticker/the-last-shift.yaml` — 30 authored headlines (freight, politics, infrastructure, sports, commission, community categories). The YAML is fully authored (Sprint 12, #306). It is NOT loaded at runtime — the content loader (`server/src/content/loader.rs`) does not parse ticker files. `ObserverSnapshotWire` in `server/src/bridge/types.rs` has no ticker field.
**What to deliver:**
1. `TickerLine` struct: `id: String`, `text: String`, `category: String`. Add to `bridge/types.rs`.
2. Add `current_ticker: Option<TickerLine>` to `ObserverSnapshotWire`. Only populated when player is in The Last Shift zone (zone_id `"bar"` — check `server/src/simulation/zone.rs`). `None` in all other zones.
3. `TickerPool` resource: loads `ticker/the-last-shift.yaml` at startup via the content loader. Holds all 30 headlines. Uses `SimRng` to advance to a new headline every `TICKER_ROTATION_TICKS = 200` ticks (20 game-minutes). Deterministic under D-010 — always use `SimRng`, never system randomness.
4. In the observer snapshot system (`server/src/perception/observer/mod.rs`), read `TickerPool` and populate `current_ticker` when player zone is `"bar"`.
**Gotcha:** The ticker rotation must use `SimRng` (the seeded bevy resource), not `rand::thread_rng()`. D-010 principle 4 — deterministic simulation. A ticker rotation driven by `thread_rng()` would produce different headlines on replay, breaking golden-file tests.
**Content note:** The ticker content file at `ticker/the-last-shift.yaml` uses a `dual_lens` field per headline (separate monologue notes for smuggler vs detective perspective). These notes are for copy authoring reference only — do NOT include them in the `TickerLine` wire struct. The `text` field is what crosses the boundary; `dual_lens` is authoring metadata.
---
### #595 — Opening monologue trigger
**What exists:** `server/src/simulation/monologue.rs``MonologueState` has `pub character: String` (initialized as `"detective"`) and `pub enter_location_fired: bool`. The system `tick_monologue()` calls `select_monologue_line()` which filters pools by `pool.character != character` — so the character string gate already works. Opening monologue content is fully authored at:
- `content/campaigns/main/.../monologue/detective/opening.yaml` (12 lines, Sprint 12 #300)
- `content/campaigns/main/.../monologue/smuggler/opening.yaml` (Sprint 12 #299)
Both files use `trigger: enter_location` and `situation: [arrival, shift_start]`. The `enter_location` trigger fires on the first tick (`MonologueState.enter_location_fired = false` → fires → sets `true`).
**What to deliver:** This ticket is small because the infrastructure is almost complete. The only missing piece is that `MonologueState.character` is initialized as `"detective"` before the archetype is known. Fix:
1. After #587 lands (archetype inserted on player entity), read `CharacterArchetype` in `MonologuePlugin.build()` or the first-tick setup system and set `MonologueState.character` correctly: `CharacterArchetype::Smuggler → "smuggler"`, `CharacterArchetype::Detective → "detective"`.
2. Verify that `select_monologue_line()` with `trigger = "enter_location"` and `character = "smuggler"` correctly selects from `opening.yaml` in the smuggler subdirectory. Run the existing monologue integration test with a smuggler archetype — it should already pass once #587 sets the character string.
3. Add a regression test: spawn two sessions (smuggler + detective), advance 1 tick each, assert different `character` on `MonologueState`. This verifies the archetype flows end-to-end.
**Blocked by:** #587 (needs `CharacterArchetype` on player entity before this system can read it).
---
### #594 — Tile data model design (D-record)
**What exists:** Current tile format is single characters (`F/W/V/R`) in string arrays in location YAML files. This cannot represent per-tile properties (door access lists, container contents, damage state, visual variant, sound properties, trigger zones). Epic #586 tracks this. This ticket produces the design only — no migration, no implementation.
**What to deliver:** A filed D-record in `decisions/architecture.md` via `tooling/db/decision claim D architecture "Tile data model — extensible per-tile properties"` before writing. The D-record must:
1. Survey what tile-level data the game needs across systems (doors, containers, damage, visual variants, trigger zones, material properties).
2. Choose between: (a) tile palette/registry (tiles are typed by ID, properties on the type), (b) per-tile property bags (each tile can have arbitrary key-value), (c) ECS-style tile components (tiles are entities), or (d) hybrid.
3. Specify the YAML authoring format (human-writable, survives merge conflicts), the loader contract (how the server parses it), and the runtime representation (what ECS queries use).
4. Estimate migration effort for the 5 existing location YAMLs.
**Scope:** This is design work only. No code changes. The D-record is the deliverable. Tyre should author it; Dudley reviews for implementation feasibility. File via the standard decision workflow (`decision claim` → edit `decisions/architecture.md` → commit with pre-commit hook running `decisions-sync`).
---
### #593 — v0.1 playthrough proof
**What exists:** By the time this ticket starts, all upstream tickets are merged: archetype in StartupMessage (#587), tell escalation (#589), ticker in snapshot (#591), opening monologue archetype-gated (#595), client character select (#588), client triangle consumer (#590), client ticker (#592), copy activation monologue lines (#597).
**What to deliver:** This is an integration proof ticket, not an implementation ticket. Deliverable is a written test plan execution + green CI.
1. **Full playthrough test (manual):** Boot server in production mode (no `--gauntlet`). Boot client in production mode (no `SR_TEST=1`). From main menu, click "New Game." Character select screen appears — select Smuggler. Game scene loads. Confirm: opening monologue fires (Smuggler voice). Walk to The Terminal. Observe Kael for ~3 game-minutes (check `EngagementRecord` via debug console `npc <id>`). Use debug console `contaminate` to skip to contamination phase. Wait for activation pass — `triangles` command shows one triangle in Active phase. Walk back to Kael — `tell_state` is `RoutineDeviation`. Proximity monologue fires (from copy #597). Walk to The Last Shift — news ticker visible. CI green.
2. **Automated integration test (server):** Add `test_v0_1_integration_playthrough` in `server/tests/`. Uses the existing test-client infrastructure (tooling/test-client): boot server, send `StartupMessage { world_seed: 12345, character_archetype: Smuggler }`, advance 1 tick, assert snapshot contains smuggler opening monologue, advance to tick 3000, send `SkipToContamination` debug command, advance 10 more ticks, assert `TriangleActivatedQueue` is non-empty in server state (via golden snapshot comparison).
3. **PR merge coordination:** Server #593 and client #588/#590/#592 must all be green on their respective branches before this ticket can be filed as done. The playthrough proof is the collective gate for the sprint.
**Gotcha — PROTOCOL_VERSION:** #587 bumps to 19. Client #588 must update `Protocol.PROTOCOL_VERSION` to 19 simultaneously. The version mismatch is a connection crash. Coordinate with Stig on merge timing.
---
## Dependency Chain
```
#587 (archetype in StartupMessage) ─────────────────────────────────────────────────┐
└→ #595 (opening monologue archetype-gated) │
└→ #588 (client: character select screen) ← client work │
#589 (tell escalation on TriangleActivated) ─────────────────────────────────────── #593
└→ #590 (client: triangle consumer) ← client work (v0.1
playthrough
#591 (news ticker in snapshot) ─────────────────────────────────────────────────── proof)
└→ #592 (client: ticker HUD) ← client work │
#597 (copy: activation monologue lines) ← copy work ────────┘
#594 (tile data model D-record) ← standalone design track, no v0.1 dependency
```
Parallel server tracks: #587, #589, #591, #594 are all independent — start all simultaneously. #595 blocked on #587.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(server): character archetype in StartupMessage and monologue gating" \
--description "body" --base main --head server
```
---
## Sprint Completion (Server Criteria)
1. `cargo test` green on server branch. All existing tests pass. New tests for archetype round-trip and tell escalation pass.
2. `StartupMessage` with `character_archetype: Smuggler` produces a smuggler player entity with `MonologueState.character = "smuggler"` on tick 1.
3. After `TriangleActivated` fires, triangle NPCs have `RoutineDeviation` inserted and `tell_state: RoutineDeviation` appears in the snapshot.
4. Snapshot contains `current_ticker` (non-null headline) when player is in `"bar"` zone.
5. Opening monologue (enter_location) fires on tick 1 with lines from the correct character pool.
6. Integration test `test_v0_1_integration_playthrough` passes end-to-end.
7. `PROTOCOL_VERSION = 19` — matches client.
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.22
version: 0.1.23
repository: settled-reach
codename: commonwealth
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.22"
version = "0.1.23"
edition = "2021"
[dependencies]
+185
View File
@@ -0,0 +1,185 @@
//! Generate snapshot fixtures from the Gauntlet test world (D-030 full pipeline).
//!
//! Runs the full Gauntlet simulation pipeline — same code path as --test-mode —
//! then serializes ObserverSnapshots to both MessagePack (wire format) and JSON
//! (human-readable debug) for client-side visual testing.
//!
//! The .msgpack files are the actual wire-format bytes the server sends over IPC.
//! Client visual tests load them via Protocol.decode_snapshot() → GameState.apply_snapshot(),
//! exercising the exact same pipeline as the live game.
//!
//! The .json files are for human inspection only.
//!
//! Run with: cargo test --test gen_gauntlet_fixtures -- --ignored
//! Or: make fixtures-gauntlet
//!
//! Output: tests/fixtures/gauntlet/*.{msgpack,json} (relative to repo root)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::npc::NpcPlugin;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition};
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::SimulationPlugin;
use settled_reach_server::test_world;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
const SEED: u64 = 42;
const FIXTURE_DIR: &str = "../tests/fixtures/gauntlet";
/// Build a deterministic Gauntlet simulation app.
/// Identical to what the server runs in --test-mode.
fn build_gauntlet(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(&mut app);
app
}
/// Teleport the player to a specific position and facing.
/// Directly modifies ECS components — same effect as PlayerAction::Teleport
/// but without needing a target room action.
fn teleport_player(app: &mut App, pos: TilePosition, facing: Facing) {
let player = {
let mut query = app
.world_mut()
.query_filtered::<Entity, With<PlayerCharacter>>();
query.single(app.world()).expect("player entity must exist")
};
app.world_mut()
.entity_mut(player)
.insert((pos, facing));
}
/// Run N ticks, feeding inputs each tick, return the last snapshot.
fn run_ticks(app: &mut App, inputs: &[Vec<PlayerInput>]) -> ObserverSnapshot {
let mut last_snapshot: Option<ObserverSnapshot> = None;
for tick_inputs in inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
last_snapshot = Some(snapshot.clone());
}
}
last_snapshot.expect("no snapshot produced")
}
/// Recursively sort all object keys for deterministic JSON output.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
fn write_fixture(name: &str, snapshot: &ObserverSnapshot) {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR);
fs::create_dir_all(&dir).expect("create fixture dir");
// MessagePack — exact wire-format bytes the server sends over IPC.
// Client visual tests load these via Protocol.decode_snapshot().
let msgpack = rmp_serde::to_vec_named(snapshot).expect("serialize to MessagePack");
let msgpack_path = dir.join(format!("{}.msgpack", name));
fs::write(&msgpack_path, &msgpack).expect("write msgpack fixture");
// JSON — human-readable debug companion (not loaded by tests).
let value: Value = serde_json::to_value(snapshot).expect("serialize to JSON");
let sorted = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted).expect("format JSON") + "\n";
let json_path = dir.join(format!("{}.json", name));
fs::write(&json_path, &json).expect("write json fixture");
eprintln!(
"Wrote {} ({} bytes msgpack, {} bytes json, {} visible_tiles, {} entities)",
name,
msgpack.len(),
json.len(),
snapshot.visible_tiles.len(),
snapshot.entities.len(),
);
}
#[test]
#[ignore] // Run manually: cargo test --test gen_gauntlet_fixtures -- --ignored
fn generate_gauntlet_snapshot_fixtures() {
// --- Hub (default spawn position) ---
// Player at (50, 58) facing North — tests fog rendering at the starting location.
// This is the exact state a new player sees on connect.
{
let mut app = build_gauntlet(SEED);
// Run 3 idle ticks to stabilize (cognitive delay, vision cone init)
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_spawn", &snapshot);
}
// --- Fog Theater (observer position) ---
// Player at (56, 18) facing South — large open room with NPCs at varying distances.
// Tests visibility cone, fog layers, and distance-based fog rendering.
{
let mut app = build_gauntlet(SEED);
let fog_theater = test_world::constants::FOG_THEATER;
teleport_player(&mut app, fog_theater.observer, fog_theater.observer_facing);
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("fog_theater", &snapshot);
}
// --- Hub after movement (explored tiles + visible tiles differ) ---
// Player moves south from hub, creating a mix of explored-but-not-visible
// and currently-visible tiles — the exact fog boundary condition.
{
let mut app = build_gauntlet(SEED);
let inputs: Vec<Vec<PlayerInput>> = vec![
vec![],
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 2,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveSouth,
}],
vec![], // idle — snapshot has explored + visible tiles that differ
];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_after_movement", &snapshot);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+95
View File
@@ -25,6 +25,14 @@ MODE="golden" # golden | screenshot | movie | update
TARGET=""
INTERVAL=""
# Server state for live scenarios
SERVER_BIN=""
SERVER_PID=""
SERVER_PORT=""
# Cleanup server on exit
trap '[[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null || true' EXIT
# -- Parse args ----------------------------------------------------------------
while [[ $# -gt 0 ]]; do
@@ -102,12 +110,84 @@ xvfb_capture() {
fi
}
# -- Server lifecycle (live scenarios) -----------------------------------------
# Check if a scenario has "live": true in config
is_live_scenario() {
python3 -c "
import json, sys
c = json.load(open('${CONFIG}'))
s = c.get('scenarios', {}).get('${1}', {})
sys.exit(0 if s.get('live') else 1)
"
}
# Build server binary (once, cached)
ensure_server_built() {
if [[ -n "$SERVER_BIN" ]]; then return 0; fi
echo " Building server for live visual tests..."
(cd "$ROOT/server" && cargo build --bin settled-reach-server 2>&1) || {
echo "Error: server build failed" >&2
return 1
}
SERVER_BIN="$ROOT/server/target/debug/settled-reach-server"
}
# Start server with --test-mode --port 0, parse LISTENING:{port}
start_server() {
local stdout_log
stdout_log=$(mktemp)
"$SERVER_BIN" --test-mode --port 0 >"$stdout_log" 2>/dev/null &
SERVER_PID=$!
local attempts=0
while [[ $attempts -lt 150 ]]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo " Error: server exited unexpectedly" >&2
rm -f "$stdout_log"
SERVER_PID=""
return 1
fi
if grep -q "^LISTENING:" "$stdout_log" 2>/dev/null; then
SERVER_PORT=$(sed -n 's/^LISTENING://p' "$stdout_log")
rm -f "$stdout_log"
echo " Server started: pid=$SERVER_PID port=$SERVER_PORT"
return 0
fi
sleep 0.1
attempts=$((attempts + 1))
done
echo " Error: no LISTENING signal after 15s" >&2
kill "$SERVER_PID" 2>/dev/null || true
rm -f "$stdout_log"
SERVER_PID=""
return 1
}
# Stop server (called after each live capture; server may have exited on disconnect)
stop_server() {
if [[ -n "$SERVER_PID" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
SERVER_PORT=""
fi
}
# -- Screenshot mode -----------------------------------------------------------
if [[ "$MODE" == "screenshot" ]]; then
mkdir -p "$CACHE_DIR"
echo "Capturing scenario: $TARGET"
if is_live_scenario "$TARGET"; then
ensure_server_built || exit 2
start_server || exit 2
export SR_LIVE=1 SR_PORT="$SERVER_PORT"
fi
godot_capture --scenario "$TARGET" "$CACHE_DIR"
stop_server
unset SR_LIVE SR_PORT 2>/dev/null || true
PNG="$CACHE_DIR/$TARGET.png"
if [[ -f "$PNG" ]]; then
echo "Screenshot: $PNG ($(stat -c%s "$PNG" 2>/dev/null || stat -f%z "$PNG") bytes)"
@@ -162,12 +242,27 @@ for scenario in "${SCENARIOS[@]}"; do
TOTAL=$((TOTAL + 1))
echo "--- $scenario ---"
# Start server for live scenarios
IS_LIVE=false
if is_live_scenario "$scenario"; then
IS_LIVE=true
ensure_server_built || { FAILED=$((FAILED + 1)); continue; }
start_server || { FAILED=$((FAILED + 1)); continue; }
export SR_LIVE=1 SR_PORT="$SERVER_PORT"
fi
# Capture
set +e
CAPTURE_OUT=$(xvfb_capture --scenario "$scenario" "$CACHE_DIR" 2>&1)
CAPTURE_RC=$?
set -e
# Stop server after capture (server exits on client disconnect anyway)
if [[ "$IS_LIVE" == "true" ]]; then
unset SR_LIVE SR_PORT 2>/dev/null || true
stop_server
fi
CAPTURED="$CACHE_DIR/$scenario.png"
if [[ $CAPTURE_RC -ne 0 ]] || [[ ! -f "$CAPTURED" ]]; then
+21
View File
@@ -2,6 +2,7 @@
"resolution": [960, 540],
"settle_frames": 30,
"tolerance": 5,
"max_diff_pct": 0.5,
"golden_dir": "client/tests/golden/visual",
"scenarios": {
"fog_3state": {
@@ -47,6 +48,26 @@
"cursor_menu": {
"ticks": 3,
"description": "Cursor rendering over dialogue option"
},
"fog_live_replay": {
"ticks": 1,
"description": "Replay real server hub snapshot — full pipeline fog test",
"replay_snapshot": "tests/fixtures/gauntlet/hub_spawn.msgpack"
},
"fog_theater_replay": {
"ticks": 1,
"description": "Replay Fog Theater — large room with NPCs at varying distances",
"replay_snapshot": "tests/fixtures/gauntlet/fog_theater.msgpack"
},
"fog_boundary_replay": {
"ticks": 1,
"description": "Replay hub after movement — explored/visible tile boundary",
"replay_snapshot": "tests/fixtures/gauntlet/hub_after_movement.msgpack"
},
"fog_live_hub": {
"ticks": 10,
"live": true,
"description": "Live server: Hub spawn fog — real pipeline regression test"
}
},
"flows": {
+24 -7
View File
@@ -239,18 +239,22 @@ def compare(
# ---------------------------------------------------------------------------
def load_tolerance(config_path: Path | None) -> int:
"""Read tolerance from config JSON, return DEFAULT_TOLERANCE on failure."""
def load_config(config_path: Path | None) -> dict:
"""Read visual test config, return dict with tolerance and max_diff_pct."""
if config_path is None:
config_path = DEFAULT_CONFIG
defaults = {"tolerance": DEFAULT_TOLERANCE, "max_diff_pct": 0.0}
if not config_path.exists():
return DEFAULT_TOLERANCE
return defaults
try:
with open(config_path) as f:
data = json.load(f)
return int(data.get("tolerance", DEFAULT_TOLERANCE))
return {
"tolerance": int(data.get("tolerance", DEFAULT_TOLERANCE)),
"max_diff_pct": float(data.get("max_diff_pct", 0.0)),
}
except (json.JSONDecodeError, ValueError, OSError):
return DEFAULT_TOLERANCE
return defaults
# ---------------------------------------------------------------------------
@@ -270,6 +274,12 @@ def main() -> int:
default=None,
help="Per-channel pixel tolerance (default: from config or 5)",
)
parser.add_argument(
"--max-diff-pct",
type=float,
default=None,
help="Max allowed diff percentage (default: from config or 0.0)",
)
parser.add_argument(
"--diff-output",
default=None,
@@ -282,9 +292,11 @@ def main() -> int:
)
args = parser.parse_args()
# Resolve tolerance: CLI > config > fallback
# Resolve settings: CLI > config > fallback
config_path = Path(args.config) if args.config else None
tolerance = args.tolerance if args.tolerance is not None else load_tolerance(config_path)
cfg = load_config(config_path)
tolerance = args.tolerance if args.tolerance is not None else cfg["tolerance"]
max_diff_pct = args.max_diff_pct if args.max_diff_pct is not None else cfg["max_diff_pct"]
# Read images
try:
@@ -322,6 +334,11 @@ def main() -> int:
return 0
pct = diff_count / total * 100
if pct <= max_diff_pct:
print(f"PASS: {diff_count} of {total} pixels differ ({pct:.1f}%, within {max_diff_pct}% threshold)")
return 0
print(f"FAIL: {diff_count} of {total} pixels differ ({pct:.1f}%)")
if args.diff_output and diff_buf: