Merge remote-tracking branch 'origin/main' into client
# Conflicts: # CLAUDE.md
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 -----------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxhgo1e4rvfmi
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://pd1qpgxiodig
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dna10a0ln5pd0
|
||||
@@ -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])
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
@@ -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.
|
||||
+83
@@ -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.
|
||||
+53
-1
@@ -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.
|
||||
|
||||
+70
-1
@@ -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"
|
||||
|
||||
+76
-1
@@ -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"
|
||||
|
||||
@@ -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.
@@ -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 (24–32px 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.
|
||||
@@ -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 3–5 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:** 3–5 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 3–5 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 — 1–2 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. 3–5 smuggler monologue lines in `monologue/smuggler/the-terminal.yaml` with `situation: [triangle_activated]`, correct tag schema (D-035), `cooldown: 9999`, `trigger: observe_npc`.
|
||||
2. 3–5 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.
|
||||
@@ -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 1–22 |
|
||||
| #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 12–22) + 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.
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.22
|
||||
version: 0.1.23
|
||||
repository: settled-reach
|
||||
codename: commonwealth
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2895
File diff suppressed because it is too large
Load Diff
+3029
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+1079
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+1607
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user