Compare commits

..
6 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 46cfec4183 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:06:14 +01:00
jpmschweitzerandClaude Opus 4.6 0472dcb86c feat(copy): triangle activation proximity monologue lines (#597)
Author 4 smuggler and 5 detective monologue lines that fire when
the player observes triangle anchor NPCs post-TriangleActivated.

Smuggler (Kael Davan): physical observation → rationalization →
doubt → sensory confirmation. Contracted, personal, friend-arc.

Detective (Sera Venn/Torek): pattern recognition → deviation →
hypothesis → inference → procedural next step. Analytical,
institutional, evidence-cataloguing.

All lines: situation: [triangle_activated], trigger: observe_npc,
cooldown: 9999, priority: 8, prerequisite: npc_in_los: true.

Schema updated: triangle_activated added to situation enum,
npc_in_los added to prerequisites in monologue-pool.schema.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:05:50 +01:00
jpmschweitzerandClaude Opus 4.6 fc24de6128 fix(client): test harness tile types, bug report screenshot, time_display maxf
TestHarness: remove deprecated tiles/visible_positions keys, add tile
type (floor/wall/door) to visible_tiles, expand radius to 5. Bug report
dialog: capture viewport screenshot before showing overlay, save as
screenshot.png in report bundle. time_display: use maxf() instead of
max() to match float argument types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:18 +01:00
jpmschweitzerandClaude Opus 4.6 ac763fef97 feat(engine): live server visual tests and gauntlet snapshot replay
Add live server lifecycle to tests/run-visual (start/stop server per
scenario, parse LISTENING:{port}). Add MessagePack snapshot replay to
visual_capture.gd via Protocol.decode_snapshot() — exercises the full
client pipeline from wire bytes to rendered fog. Three replay scenarios
(hub_spawn, fog_theater, hub_after_movement) plus one live scenario
(fog_live_hub). Add gen_gauntlet_fixtures.rs to produce .msgpack fixtures
from the Gauntlet test world. Add max_diff_pct threshold to visual-diff.
Makefile: add fixtures-gauntlet target, fix build-client double-import,
preserve .godot cache in clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:09 +01:00
jpmschweitzerandClaude Opus 4.6 2189b00c6f fix(client): move fog blur to CPU pipeline, fix GL compat bilinear on RGBA8
Replaces GPU 7×7/5×5 Gaussian blur (98 texture reads/px) with CPU-side
Gaussian blur (sigma 2.0) + 4× bilinear upscale + RGBA8 convert in
fog_state.gd. GL compatibility mode doesn't bilinear-filter R8 textures;
RGBA8 at 4× resolution resolves this. Squared exp_fade at the
explored/unexplored boundary keeps fog opaque near tile content edges,
fixing the staircase artifact. Shader now does 2 texture reads per pixel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:40:57 +01:00
jpmschweitzerandClaude Opus 4.6 a75d9f6c08 chore(meta): plan Sprint 24: Signal
10 tickets across server (6), client (3), copy (1).
Capstone sprint for v0.1 — everything converges on a full
playthrough from main menu through storyteller activation.

Closed stale epics: #38, #369, #455, #575, #596.
Sprint goal: wire TriangleActivated into player-visible signal,
thread character archetype through session lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:42:30 +01:00
33 changed files with 9895 additions and 120 deletions
+3
View File
@@ -6,6 +6,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Triangle activation proximity monologue lines — 4 smuggler lines (Kael Davan) and 5 detective lines (Sera Venn/Torek Lintar) that fire when observing triangle anchor NPCs post-activation (#597, D-039)
## [v0.1.23] — 2026-03-04
### Added
+8 -2
View File
@@ -5,7 +5,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
@@ -110,6 +110,9 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@# First import may error on theme/font loading before the import scan completes.
@# Run twice: first pass generates imports silently, second pass validates clean.
@$(GODOT) --headless --path client --import --quit 2>/dev/null || true
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -144,6 +147,9 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-gauntlet:
cd server && cargo test --test gen_gauntlet_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
@@ -347,5 +353,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot/* client/reports
rm -rf client/reports
@echo "Clean complete."
+10 -17
View File
@@ -179,9 +179,7 @@ func snapshot() -> Dictionary:
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"tiles": _tiles(),
"visible_tiles": _visible_tiles(),
"visible_positions": _visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
"current_dialogue": dialogue,
@@ -229,7 +227,7 @@ func _visible_tiles() -> Array:
var vtiles: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
var radius := 5
var room_x := 7
var room_y := 7
var room_w := 8
@@ -241,27 +239,22 @@ func _visible_tiles() -> Array:
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
var sector: String = "Forward" if y <= py else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
return vtiles
func _visible_positions() -> Array:
var positions: Array = []
var px := player_pos.x
var py := player_pos.y
var radius := 4
func _get_tile_type(x: int, y: int) -> String:
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(px - radius, px + radius + 1):
for y in range(py - radius, py + radius + 1):
var dist := absf(x - px) + absf(y - py)
if dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
positions.append({"x": x, "y": y})
return positions
var is_edge := (x == room_x or x == room_x + room_w - 1
or y == room_y or y == room_y + room_h - 1)
if is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
return "door"
return "wall"
return "floor"
# -- Spatial helpers -----------------------------------------------------------
+1
View File
@@ -32,6 +32,7 @@ func _ready() -> void:
# Create seamless noise texture for fog animation
var noise := FastNoiseLite.new()
noise.seed = 42
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.03
var noise_tex := NoiseTexture2D.new()
+21 -59
View File
@@ -1,17 +1,21 @@
shader_type canvas_item;
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
// State 1: Clear (forward cone) — transparent, soft gradient edge (6-8 tile radius)
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
// State 3: Unexplored — solid near-black #12141a
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
//
// Texture pipeline (fog_state.gd): binary 0/255 at 1× tile resolution → CPU Gaussian blur
// (sigma 2.0, 6-8 tile gradient) → Image.resize 4× bilinear upscale → RGBA8 convert.
// Textures arrive here with smooth sub-tile gradients — no GPU-side blur needed.
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
uniform sampler2D visibility_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D exploration_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // D-073: hard zone boundaries
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
@@ -23,40 +27,6 @@ uniform bool debug_exploration = false; // When true, render raw exploration tex
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
float sample_visibility(vec2 uv) {
vec2 t = 2.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
for (float dx = -3.0; dx <= 3.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 8.0);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(visibility_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
// Soft gradient on exploration boundary (5x5, sigma 1.5).
// Prevents hard tile-stepped staircase at explored/unexplored edge.
float sample_exploration(vec2 uv) {
vec2 t = 1.0 / map_size;
float sum = 0.0;
float weight = 0.0;
for (float dy = -2.0; dy <= 2.0; dy += 1.0) {
for (float dx = -2.0; dx <= 2.0; dx += 1.0) {
float w = exp(-(dx * dx + dy * dy) / 4.5);
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
sum += texture(exploration_tex, sample_uv).r * w;
weight += w;
}
}
return sum / weight;
}
void fragment() {
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
@@ -67,28 +37,15 @@ void fragment() {
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
// Debug mode: render raw exploration texture (bypass fog rendering).
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
// Debug mode: render RAW exploration texture with filter_linear.
// If hardware bilinear works, should show smooth sub-tile gradients.
} else if (debug_exploration) {
float explored_dbg = texture(exploration_tex, tex_uv).r;
if (explored_dbg > 0.9) {
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
} else if (explored_dbg > 0.1) {
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
} else {
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
}
float explored_raw = texture(exploration_tex, tex_uv).r;
COLOR = vec4(vec3(explored_raw), 0.9);
} else {
float vis_raw = texture(visibility_tex, tex_uv).r;
float vis = sample_visibility(tex_uv);
float explored_raw = texture(exploration_tex, tex_uv).r;
float explored = sample_exploration(tex_uv);
// Prevent gradient bleed into never-explored tiles (use raw, unblurred value)
if (explored_raw < 0.01 && vis_raw < 0.01) {
vis = 0.0;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black — information zero
@@ -106,8 +63,13 @@ void fragment() {
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Soft edge between explored and unexplored (blurred to avoid staircase)
float exp_fade = smoothstep(0.0, 0.3, explored);
// Soft edge between explored and unexplored.
// The exploration texture is binary (explored-or-not) blurred over ~12 tiles.
// At the physical tile boundary, explored ≈ 0.5. Squaring the fade keeps
// fog nearly opaque there (97%), hiding tile-aligned content edges.
// Content appears gradually 4-6 tiles inside the explored area.
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper curve: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
@@ -0,0 +1 @@
uid://c1dnlbnxtgqqo
+1
View File
@@ -0,0 +1 @@
uid://bxhgo1e4rvfmi
+78
View File
@@ -24,6 +24,7 @@ var _flow: String = ""
var _interval: float = 3.0
var _list_mode: bool = false
var _config: Dictionary = {}
var _is_live: bool = false
func _init():
@@ -73,6 +74,19 @@ func _run():
return
var main_node = main_scene.instantiate()
# Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim()
_is_live = OS.get_environment("SR_LIVE") == "1"
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var port_env := OS.get_environment("SR_PORT")
if port_env.is_empty():
push_error("visual_capture: SR_LIVE=1 but SR_PORT not set")
quit(1)
return
sim_bridge.server_port = int(port_env)
print("visual_capture: live mode — server port %d" % sim_bridge.server_port)
root.add_child(main_node)
# Wait for NoiseTexture2D async generation
@@ -92,6 +106,36 @@ func _run():
for i in range(settle_count):
await process_frame
# Live mode: wait for server connection and first snapshot
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var game_state := root.get_node("/root/GameState")
print("visual_capture: waiting for server connection...")
var max_frames := 300 # 5 seconds at 60fps
var waited := 0
while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED:
if sim_bridge.state == sim_bridge.ConnectionState.ERROR:
push_error("visual_capture: server connection failed")
quit(1)
return
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: connection timeout after %d frames" % waited)
quit(1)
return
print("visual_capture: connected after %d frames" % waited)
# Wait for first snapshot from server
waited = 0
while game_state.current_tick == 0:
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: no snapshot after %d frames" % waited)
quit(1)
return
print("visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited])
if not _scenario.is_empty():
await _run_scenario(main_node)
elif not _flow.is_empty():
@@ -123,6 +167,40 @@ func _run_scenario(_main_node: Node) -> void:
# Post-tick setup (e.g. zone tint patching)
_scenarios.post_setup(_scenario, root)
# Replay snapshot: inject a real server snapshot through the FULL client pipeline.
# Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd,
# then applies through GameState → FogState → shader — same path as live game.
var replay_path: String = scenario_cfg.get("replay_snapshot", "")
if not replay_path.is_empty():
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
var abs_path := repo_root.path_join(replay_path)
var rf := FileAccess.open(abs_path, FileAccess.READ)
if rf == null:
push_error("visual_capture: cannot open replay snapshot %s" % abs_path)
quit(1)
return
var replay_bytes := rf.get_buffer(rf.get_length())
rf.close()
# Decode through Protocol.decode_snapshot() — same as live IPC receive path.
# This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc.
var replay_data: Variant = Protocol.decode_snapshot(replay_bytes)
if replay_data == null or not replay_data is Dictionary:
push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path)
quit(1)
return
print("visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [
replay_path, replay_bytes.size(),
str(replay_data.get("tick", "?")),
replay_data.get("visible_tiles", []).size()])
var game_state := root.get_node("/root/GameState")
var fog_state := root.get_node("/root/FogState")
game_state.apply_snapshot(replay_data)
fog_state.update_from_state()
# Extra frames for fog uniform propagation
for i in range(4):
await process_frame
# Extra frames for state propagation + viewport texture lag
await process_frame
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://pd1qpgxiodig
+11
View File
@@ -83,6 +83,17 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
sim_bridge.harness.player_pos = Vector2i(11, 9)
sim_bridge.harness.process_input("Interact")
"fog_live_replay", "fog_theater_replay", "fog_boundary_replay":
# Replay real server snapshots via MessagePack → Protocol.decode_snapshot().
# Setup handled by visual_capture.gd (reads replay_snapshot from config).
pass
"fog_live_hub":
# Live server connection — Hub spawn position.
# No setup needed: server starts in --test-mode with Gauntlet,
# player spawns at Hub (50,58). Captures real fog pipeline output.
pass
_:
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
return false
+1
View File
@@ -0,0 +1 @@
uid://dna10a0ln5pd0
+16 -1
View File
@@ -11,6 +11,7 @@ extends Control
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
## - screenshot.png — viewport capture taken before dialog opened
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
@@ -36,6 +37,7 @@ const RING_SIZE := 60
var _line_edit: LineEdit = null
var _active: bool = false
var _captured_screenshot: Image = null
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
@@ -201,6 +203,8 @@ func _get_filled_snapshot_count() -> int:
func start_capture() -> void:
if _active:
return
# Capture screenshot BEFORE showing the dialog overlay
_captured_screenshot = get_viewport().get_texture().get_image()
_active = true
visible = true
@@ -247,6 +251,8 @@ func _close() -> void:
_line_edit.queue_free()
_line_edit = null
_captured_screenshot = null
# Unpause the simulation
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
@@ -345,7 +351,16 @@ func _save_report(description: String) -> void:
else:
push_error("BugReport: failed to write %s" % seed_path)
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
# 7. screenshot.png — viewport capture taken before dialog opened
if _captured_screenshot:
var screenshot_path := base_path + "/screenshot.png"
var img_err := _captured_screenshot.save_png(screenshot_path)
if img_err == OK:
files_saved += 1
else:
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [
files_saved, base_path, _input_count])
+1 -1
View File
@@ -70,7 +70,7 @@ func _cache_geometry() -> void:
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var meta_w := _phase_size.x + _day_size.x
var content_w := max(_time_size.x, meta_w)
var content_w := maxf(_time_size.x, meta_w)
_box_w = content_w + PADDING.x * 2
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
+7 -2
View File
@@ -75,12 +75,13 @@
"emergency",
"routine",
"observation",
"greeting"
"greeting",
"triangle_activated"
]
},
"minItems": 1,
"uniqueItems": true,
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 14 v0.1 values. The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 15 v0.1 values (triangle_activated added Sprint 24). The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
},
"trigger": {
"type": "string",
@@ -124,6 +125,10 @@
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
},
"npc_in_los": {
"type": "boolean",
"description": "Gate: line only fires when the triggering NPC is in the player's line of sight. Added Sprint 24 for triangle_activated observe_npc lines."
}
}
}
@@ -446,3 +446,77 @@ lines:
state: person_of_interest
priority: 8
tags: [npc, kael, investigation, evidence, analytical]
# --- Triangle Activation: Sera Venn / Torek Lintar (T2 informant-question) ---
# Fires post-TriangleActivated when player has LOS to Sera or Torek. One beat per line.
# Beat 1: pattern recognition. Beat 2: deviation logged. Beat 3: hypothesis. Beat 4: inference. Beat 5: procedural next step.
- id: pc-detective_m_d_041
text: "Venn changed her transit route. Bay six to bay four bypass — three times today."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_042
text: "She passed Torek without acknowledgment. Third instance logged. They have worked adjacent bays for months."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, torek, behavioral]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_043
text: "If she knows about the manifest discrepancy, why has she not reported it?"
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_044
text: "She is protecting something. Or someone. The distinction matters."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, analytical, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-detective_m_d_045
text: "Worth a direct conversation. Controlled setting. Not here."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, sera, procedural, friend-arc]
prerequisites:
npc_in_los: true
@@ -373,3 +373,77 @@ lines:
state: person_of_interest
priority: 9
tags: [npc, kael, contaminated-trust, friend-arc]
# --- Triangle Activation: Kael Davan (T1 hub-power) ---
# Fires post-TriangleActivated when player has LOS to Kael. One beat per line.
# Beat 1: physical observation. Beat 2: rationalization. Beat 3: doubt. Beat 4: doubt breaks.
- id: pc-smuggler_m_s_036
text: "Kael's in the main corridor. He doesn't usually come through here."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_037
text: "Voss probably moved him. Schedule shifts happen."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [suspicious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_038
text: "...Kael would've told me."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_039
text: "He's not looking at the cargo. He's watching the exits."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, tell, friend-arc]
prerequisites:
npc_in_los: true
- id: pc-smuggler_m_s_040
text: "I don't know what I'm looking at. But I know Kael. And this isn't Kael."
role: player_character
access: [public]
trust: surface
situation: [triangle_activated]
trigger: observe_npc
mood: [anxious]
priority: 8
cooldown: 9999
tags: [triangle-signal, tell-observation, npc, kael, contradiction, friend-arc]
prerequisites:
npc_in_los: true
+37 -31
View File
@@ -10,16 +10,23 @@ The fog system was simplified from the original 5-layer spec in Sprint 22 (#569)
| Original D-059 Layer | Sprint 22 Status | Notes |
|----------------------|------------------|-------|
| 1. Clear (forward cone) | Implemented | Soft Gaussian gradient (7x7, sigma 2.0) |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); Gaussian blur provides soft transition |
| 3. Deep fog (previously explored) | Implemented — EXP_EXPLORED | Alpha 0.55-0.70 with zone temperature tint (#563) |
| 1. Clear (forward cone) | Implemented | CPU Gaussian blur (sigma 2.0) → 4× bilinear upscale → RGBA8. Smooth sub-tile gradients. |
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); CPU blur + bilinear upscale provides soft transition |
| 3. Deep fog (previously explored) | Simplified — D-015 light fog | Alpha 0.25-0.35 with zone temperature tint. Explored tiles show through light haze. |
| 4. Unexplored + maps app | Deferred | v0.1.2+, requires mapped_tiles in ObserverSnapshot |
| 5. Unexplored (no maps) | Implemented | Solid near-black #12141a |
**Alpha values (Sprint 22, #563):**
- Light fog zone (near cone gradient, vis 0.0-0.3): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.55-0.70, breathing ±0.075 (15-20s)
- Zone temperature tint active in deep fog zone (D-059/D-046/D-077)
**Texture pipeline (fog_state.gd):**
Binary 0/255 data at 1× tile resolution → CPU Gaussian blur (sigma 2.0, radius 4)
→ `Image.resize()` 4× bilinear upscale → `Image.convert()` RGBA8.
GL compat mode doesn't bilinear-filter R8 textures; RGBA8 at 4× resolves this.
Exploration data is binarized (0/128/255 → 0/255) before blur to avoid a
second gradient at the explored/visible boundary.
**Alpha values (Sprint 22, D-015):**
- Light fog zone (explored, out of cone): alpha 0.25-0.35, breathing ±0.05 (8-10s)
- Zone temperature tint active in explored zone (D-059/D-046/D-077)
- Explored/unexplored boundary: squared fade keeps fog opaque at tile content edge
## Overview
@@ -120,30 +127,27 @@ func update_from_state() -> void:
File: `client/shaders/fog.gdshader`
The shader determines fog state per pixel based on the visibility and exploration textures.
Two explored sub-zones are distinguished by the blurred `vis` value (proximity to the forward cone):
The shader reads pre-smoothed RGBA8 textures (CPU blur + 4× bilinear upscale)
and determines fog state per pixel. No GPU-side blur — 2 texture reads per pixel.
```glsl
// Light fog noise — fast cycle (8-10s), subtle ±0.05 breathing
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// Deep fog noise — slow cycle (15-20s), more pronounced ±0.075 breathing
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05; // 0.25-0.35
// Light fog: alpha 0.25-0.35 (near cone gradient)
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
// Deep fog: alpha 0.55-0.70 (far from cone, EXP_EXPLORED)
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
// Blend deep <-> light fog by proximity to cone:
// vis=0 (far from cone) → deep_factor=1 → deep fog color + alpha
// vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
// vis=0.85+ (inside cone) → clarity=1 → transparent (clear)
float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);
// Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
// Clarity ramp: transparent inside cone, light fog at edges and beyond
float clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);
// Explored/unexplored boundary: squared fade hides tile content edges
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade; // Steeper: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
```
**Zone temperature palette (D-046):**
@@ -215,11 +219,13 @@ Per tick (in _process or on snapshot signal):
| Component | Budget | Estimate | Notes |
|-----------|--------|----------|-------|
| Visibility texture upload | 0.1ms | ~0.05ms | 400 pixels via set_pixel() |
| Exploration texture update | 0.1ms | ~0.05ms | Incremental — only changed tiles |
| Fragment shader (1080p) | 0.5ms | ~0.2ms | Single full-screen pass, simple math |
| CPU Gaussian blur (1×, 40×40) | 0.2ms | ~0.1ms | Separable, sigma=2.0, radius=4 (vis: 1 pass, exp: 2 passes) |
| Image.resize C++ (40→160) | 0.1ms | ~0.05ms | INTERPOLATE_BILINEAR, 2× textures |
| Image.convert R8→RGBA8 | 0.1ms | ~0.02ms | GL compat bilinear requires RGBA8 |
| Texture upload (RGBA8 160×160) | 0.2ms | ~0.1ms | 2× textures, ~200KB total |
| Fragment shader (1080p) | 0.2ms | ~0.05ms | 2 texture reads/px (was 98 with GPU blur) |
| Fog entity sprites | 0.2ms | ~0.05ms | 0-15 sprites, trivial draw calls |
| **Total** | **<1ms** | **~0.35ms** | Well within D-059 budget |
| **Total** | **<1ms** | **~0.37ms** | Well within D-059 budget |
## Files to Create
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
# Sprint 24: Signal — Client Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Client work this sprint wires the player-facing signal
> that makes the simulation legible as a story: the character select screen, the triangle
> activation response, and the news ticker. All three must land before #593 (playthrough proof)
> can be filed as done. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #588 | Character archetype selection — client: character select screen before session start | #587 (server: archetype in StartupMessage) |
| #590 | Triangle activation consumer — client: react to triangle_crisis_events (monologue chime + urgent overlay) | #589 (server: tell escalation emitted) |
| #592 | News ticker — client: scrolling ticker HUD element in The Last Shift zone | #591 (server: ticker in snapshot) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests this sprint's work must satisfy), D-039 (wow moments — #1 Arrival: opening monologue; #5 News Ticker Gut-Punch: same ticker, opposite monologue reactions)
- `decisions/architecture.md` — D-020 (ObserverSnapshot is the only data crossing IPC; StartupMessage is the client→server init message; PROTOCOL_VERSION gates wire compatibility), D-042 (UI microcopy in `client/data/ui-strings.yaml` via UIStrings autoload)
- `decisions/content.md` — D-032 (separate monologue pools per character — client does not select the pool; the character string in StartupMessage drives server-side selection), D-074 (audio aesthetic — monologue chime = insert-tech: synthetic, precise, no reverb)
- `decisions/perception.md` — D-067 (recognition chime fires at onset of cognitive delay — `sfx_monologue_chime_urgent.ogg` is the correct asset for triangle activation), D-016 (internal monologue as perception bridge — client only displays what server sends; no client-side monologue logic)
---
## Notes
### #588 — Character archetype select screen
**What exists:** `client/ui/main_menu.gd` (130 lines) — "New Game" button triggers `SessionManager.new_game()` which creates a save directory and seeds `GameState.world_seed`. It then loads `GAME_SCENE` directly, with no character selection step. `client/scripts/autoloads/session_manager.gd` — `new_game()` returns a `game_id` but does not record which archetype was chosen. `client/scripts/autoloads/game_state.gd` — check whether a `character_archetype` field already exists (likely not — add it). `CharacterArchetype` is a server-side enum; the client needs to record the chosen value as a string (`"smuggler"` or `"detective"`) and include it in `StartupMessage` sent over IPC.
**What to deliver:**
1. `GameState.character_archetype: String` — new field, default `"detective"`. Persisted alongside `world_seed` in the save directory (`user://saves/<game-id>/character.txt` or extend the existing seed file format).
2. Character select scene — insert a step between "New Game" and loading `main.tscn`. This can be a new scene (`client/scenes/character_select.tscn`) or a modal panel within `main_menu.tscn`. Show two options: **Smuggler** and **Detective**. Each option shows the character name, a one-line role description, and a two-line tone description (see below). On selection, set `GameState.character_archetype`, then proceed to `main.tscn`.
**Smuggler card:**
- Name: `Smuggler`
- Role: `Freight logistics worker — Sova Transit`
- Tone: `Insider access. Social camouflage. The ring is your daily life.`
**Detective card:**
- Name: `Detective`
- Role: `Commission investigator — External assignment`
- Tone: `Institutional authority. Analytical lattice. You were sent here.`
These strings belong in `client/data/ui-strings.yaml` (D-042), not hardcoded in GDScript.
3. `Protocol.encode_startup_message()` update — add `character_archetype` to the StartupMessage dict before it is serialized. The server's `StartupMessage` struct now has `pub character_archetype: CharacterArchetype` (#587). Map client string `"smuggler"` → server enum variant `Smuggler`. In MessagePack/GDScript, this is just a string field added to the dict: `{ "world_seed": ..., "character_archetype": "Smuggler" }`.
4. `Protocol.PROTOCOL_VERSION = 19` — bump to match server #587. The client must send the new version on handshake. This is a **hard coordination point** with Dudley — client and server PRs must land together or in the same merge window. A version mismatch will crash the connection on the handshake check.
**UI constraints:** The character select screen must feel intentional, not an afterthought. Two full-width cards, dark background, character name in the sprint's color palette (consistent with main menu). No portraits (art is deferred). Cards are selectable via keyboard (left/right arrows) and mouse click. The selection is confirmed with Enter or a "Begin" button. ESC cancels back to the main menu without creating a save directory.
**Non-obvious gotcha:** `SessionManager.new_game()` currently creates the save directory before any game scene loads. The character select step happens after `new_game()` creates the directory but before the game scene loads. `GameState.character_archetype` must be set before `SimBridge` sends the `StartupMessage` — which happens when `main.tscn` is ready and `SimBridge._ready()` connects to the server. Verify the ordering: `new_game()` → character select panel → user picks archetype → `GameState.character_archetype` set → `main.tscn` loads → `SimBridge._ready()` fires → `StartupMessage` includes archetype.
**Blocked by:** #587 (server must define `character_archetype` field in `StartupMessage` before client serialization is finalized).
---
### #590 — Triangle activation consumer
**What exists:** `client/scripts/snapshot_event_router.gd` — routes snapshot fields to registered handlers. `client/scripts/main.gd` — registers handlers on `_router`. `client/scripts/autoloads/sim_bridge.gd` — `_on_snapshot_received()` decodes and emits snapshot. `client/scripts/protocol/protocol.gd` — `decode_snapshot()` returns a dict from the MessagePack bytes. The server snapshot wire type (`ObserverSnapshotWire`) has a `triangle_crisis_events: Vec<TriangleCrisisEventWire>` field (see `server/src/bridge/types.rs` line ~181). This field is present in the MessagePack output. **The client currently ignores it entirely** — there is no decode path for `triangle_crisis_events` in `protocol.gd` and no handler registered in `main.gd`.
**What to deliver:**
1. **Decode `triangle_crisis_events`** in `protocol.gd` `decode_snapshot()`. The field is an array of dicts, each with at minimum `{ "triangle_id": int }`. Add it to the returned snapshot dict as `"triangle_crisis_events": Array`.
2. **Handle activation in `main.gd`** — register a handler that reads `triangle_crisis_events` from the snapshot. When the array is non-empty (at least one event), fire the urgent monologue chime: `AudioManager.play_one_shot(AudioManager.CHIME_RECOGNITION, AudioManager.BUS_UI_SOUNDS)` — wait, check the constant name. The correct asset is `sfx_monologue_chime_urgent.ogg` (D-038, D-067 "sharper variant for contradiction/anomaly"). `AudioManager` has `const CHIME_RECOGNITION := "sfx_monologue_chime"` — add `const CHIME_ACTIVATION := "sfx_monologue_chime_urgent"` if it doesn't exist, then call `AudioManager.play_one_shot(CHIME_ACTIVATION, BUS_UI_SOUNDS)`.
3. **Deduplication** — the triangle activation is a one-shot event (v0.1 fires once per session per D-072/D-089). The client must not fire the chime on every subsequent tick that includes the event in the array. Track activated triangle IDs in a local `Set` in `main.gd`. If `triangle_id` is already in the set, skip. Add to set on first encounter.
4. **No overlay UI** — the monologue chime is the client-side signal. The copy team (#597) authors the proximity monologue lines that fire when the player observes the activated NPC's `tell_state: RoutineDeviation`. The client does not need to render a special overlay or notification — the tell state on the entity and the subsequent proximity monologue are the visible consequence. Keep client reaction to: chime + deduplication tracking only.
**Why no overlay:** D-039 wow moment #2 ("The Character's Eye") is about the monologue noticing something the player didn't. Adding a UI overlay would make it a notification, not a character observation. The feel is: you're wandering near Kael, suddenly you hear the chime — then the next monologue line is your character's internal voice noticing something is off. The server sends the `tell_state: RoutineDeviation` on the NPC entity; the client's entity renderer already renders this as visible entity data that can trigger `observe_npc` monologue.
**Blocked by:** #589 (server must send non-empty `triangle_crisis_events` for client to handle).
---
### #592 — News ticker HUD
**What exists:** `client/ui/hud.gd` and `hud.tscn` — main HUD container. `client/ui/time_display.gd` — insert-style time display already wired via `_router.register_always(time_display.update_from_state)` in `main.gd`. No ticker node or script exists. Server snapshot will carry `current_ticker: Optional<{ id, text, category }>` when player is in the bar zone (#591).
**What to deliver:**
1. `client/ui/news_ticker.gd` + `news_ticker.tscn` — a horizontal scrolling text bar. Design: narrow strip (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.
+147
View File
@@ -0,0 +1,147 @@
# Sprint 24: Signal — Copy Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `copy`
**Agents:** Mellanie (dialogue), Paula (narrative), Gestalt (systems)
> **This is the capstone sprint for v0.1.** The lines authored here are the player's first
> experience of the storyteller doing its job. When the triangle activates and Kael's behavior
> shifts, the character's internal voice is the signal. These lines carry D-039 wow moment #2
> ("The Character's Eye") — the moment the monologue notices something the player didn't.
> Sprint 25 is playtest. Get these lines right.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #597 | Triangle activation — copy: author 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.
+120
View File
@@ -0,0 +1,120 @@
# Sprint 24: Signal — Joint / Integration
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Agents:** All implementation agents (Dudley, Tyre, Hoshe, Stig, Mellanie, Paula, Gestalt)
> **This is the capstone sprint for v0.1.** Sprint 23 built the world. Sprint 24 makes it
> legible as a story. Sprint 25 is playtest. Every ticket in this sprint feeds into a single
> proof: a real human can boot the game, pick a character, play for 30 minutes, and experience
> the storyteller doing its job — without scripted interventions or debug shortcuts.
---
## Pre-Sprint Actions
These must happen at sprint start, before implementation tickets begin:
| Action | Owner | Blocks |
|--------|-------|--------|
| Confirm `StartupMessage` wire format with `character_archetype` field | Dudley + Stig sync (day 1) | #588 — client serialization cannot be finalized until server field is published in `bridge/types.rs` |
| Confirm PROTOCOL_VERSION bump: 18 → 19 | Dudley + Stig | #587 server + #588 client must land together — version mismatch crashes handshake |
| Confirm `triangle_crisis_events` decode path for `TriangleCrisisEventWire` field names | Dudley + Stig sync (day 1) | #590 — client `protocol.gd` decode speculates from `bridge/types.rs`; confirm field names before merging |
| Confirm `current_ticker` snapshot field structure (`id`, `text`, `category`) | Dudley + Stig sync (day 1) | #592 — client ticker decode needs field names from server `bridge/types.rs` |
---
## Cross-Team Dependencies
| Dependency | Direction | Notes |
|------------|-----------|-------|
| `character_archetype` in StartupMessage | Server (#587) → Client (#588) | Client cannot send archetype until server defines the field. Stig reads `bridge/types.rs` for the field name before finalizing `Protocol.encode_startup_message()`. |
| PROTOCOL_VERSION 18 → 19 | Server (#587) ↔ Client (#588) | Both must land in the same merge window. A mismatch crashes the handshake. |
| `RoutineDeviation` tell in snapshot | Server (#589) → Client (#590) | Client registers `triangle_crisis_events` handler; server populates it after tell escalation is wired. |
| `current_ticker` in snapshot | Server (#591) → Client (#592) | Client ticker hides when field is absent (null). Client can be merged before server; it simply renders nothing until server sends data. |
| Triangle activation monologue lines | Copy (#597) → Server (#593) | Lines must be in YAML files before playthrough proof run. Copy is unblocked — merge first. |
| Playthrough proof | All tickets → Server (#593) | #593 is the convergence gate. Cannot be filed done until all upstream tickets are merged and CI is green across all branches. |
---
## Housekeeping Done at Sprint Planning
The following tickets were closed during planning as their work was already complete:
| # | Reason |
|---|--------|
| #38 | Client-Server Integration epic — all 11 children done across Sprints 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.
+199
View File
@@ -0,0 +1,199 @@
# Sprint 24: Signal — Server Tasks
**Goal:** Wire the storyteller's activation event into player-visible consequences, thread character archetype through the full session lifecycle, and deliver the first unscripted end-to-end v0.1 playthrough — from main menu to triangle activation.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
> **This is the capstone sprint for v0.1.** Everything server-side must converge on a production
> playthrough: correct character spawned, opening monologue fired, storyteller active, triangle
> escalation observable. Sprint 25 is playtest. There is no Sprint 26 before v0.1 ships.
---
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #587 | Character archetype — server: accept archetype in StartupMessage, spawn correct PC | — |
| #589 | Triangle activation consumer — server: behavioral tell escalation on TriangleActivated | — |
| #591 | News ticker — server: load ticker YAML, emit current headline in snapshot | — |
| #595 | Opening monologue trigger — server: emit opening lines at session start based on archetype | #587 |
| #593 | v0.1 playthrough proof: full session from main menu through storyteller activation | #587, #589, #591, #595 (+ client #588, #590, #592 + copy #597) |
| #594 | Tile data model design — produce D-record for extensible tile properties | — (design only, unblocks post-v0.1) |
Use `tooling/db/ticket show <id>` for full details.
---
## Key Decisions
- `decisions/scope.md` — D-027 (vertical slice success criteria — the 4 tests the playthrough must satisfy), D-039 (6 wow moments — opening arrival, character's eye, FRIEND contradiction, divergence reveal, news ticker gut-punch, quiet moment)
- `decisions/content.md` — D-032 (separate monologue pools: `character` tag is a hard partition, not a filter), D-035 (tag taxonomy: `trigger`, `character`, `prerequisite` fields on monologue lines), D-023 (three-tier content model — storyteller activates Tier 1 modules based on engagement)
- `decisions/content.md` — D-024 (NPC 10-axis model — tell system axis 9; tells flow from simulation state not authored), D-036 (Sova Transit District — The Terminal, The Last Shift, news ticker content at `ticker/the-last-shift.yaml`)
- `decisions/architecture.md` — D-020 (StartupMessage is the only initialization crossing IPC; PROTOCOL_VERSION gates wire compatibility), D-041 (knowledge graph data model — KnowledgeConfidence confidence levels), D-010 principle 3 (no player identity baked into game loop — archetype is a configuration, not a special case)
---
## Open Questions to Resolve Early
- **Q-052: Storyteller hint delivery channels** — #589 implements channels 1 (behavioral tell escalation) and 7 (proximity monologue, via copy #597). Environmental change (channel 2) and overheard NPC conversation (channel 4) are deferred to Sprint 25. This sprint resolves Q-052 at v0.1 scope. No design discussion needed — the channel inventory is decided; implementation scope is constrained to what's achievable this sprint.
---
## Notes
### #587 — Character archetype in StartupMessage
**What exists:** `StartupMessage` in `server/src/bridge/types.rs` has one field: `pub world_seed: u64`. The production startup path initializes all NPCs and spawns the player entity without any archetype selection — the player entity carries `CharacterArchetype::Detective` by default (see `server/src/simulation/monologue.rs` line ~174: `character: "detective".to_string()`). `CharacterArchetype` enum is already defined in `bridge/types.rs` with `Smuggler` and `Detective` variants (used by phase-2 verb filter in `server/src/perception/observer/mod.rs`).
**What to deliver:**
1. Add `pub character_archetype: CharacterArchetype` to `StartupMessage`. Default to `Detective` if absent during deserialization (backward-compatible via `#[serde(default)]`).
2. In the production startup path (`server/src/main.rs` or `server/src/content/spawn.rs`), read `startup_msg.character_archetype` and insert the correct `CharacterArchetype` component on the player entity. The player entity is currently spawned without an archetype component — insert it here.
3. In `server/src/simulation/monologue.rs`, read the `CharacterArchetype` component from the player entity at `MonologueState` initialization (or on first tick) and set `MonologueState.character` from it: `"smuggler"` or `"detective"`. This is the string that gates all monologue pool selection.
4. Bump `PROTOCOL_VERSION` to 19. The new field in `StartupMessage` is a breaking change — old clients send a message that omits `character_archetype`, new server will default it correctly, but old servers receiving the new format will fail. Coordinate with Stig (#588) on timing.
**Gotcha:** `CharacterArchetype` already serializes via serde. The existing `impl From<CharacterArchetype> for ObserverSnapshot` path in bridge tests validates round-trips — make sure the new `StartupMessage` test also covers the default case (`Smuggler` serializes and round-trips; missing field deserializes as `Detective`).
**Unblocks:** #595 (opening monologue), #588 (client select screen can now send the archetype).
---
### #589 — Triangle activation consumer: behavioral tell escalation
**What exists:** `server/src/npc/tell_state.rs` — `TellCategory` enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation) and `derive_tell_state()` function. Tell state is derived from NPC axis values each tick and emitted in `ObserverSnapshot.entities[].tell_state`. `RoutineDeviation` component exists. The storyteller emits `TriangleActivated { triangle_id, npc_entity }` into `TriangleActivatedQueue`.
**What to deliver:** A system `escalate_tells_on_activation()` that:
1. Drains `TriangleActivatedQueue` each tick (non-destructively — queue must still be readable by other consumers; use `events()` pattern or check if existing drain is appropriate).
2. For each `TriangleActivatedEvent`, find the anchor NPC entity (`event.npc_entity`) and the 1-2 NPCs who are in the same triangle (via `TriangleState` query). These are the triangle's NPCs.
3. Insert a `RoutineDeviation` component on all triangle NPCs. `RoutineDeviation` is the strongest tell category per `derive_tell_state()` priority order — it overrides Nervous/Angry/Guarded. This makes the triangle NPCs immediately observable as anomalous.
4. The `RoutineDeviation` component should carry a `expires_at_tick: u64` field (if not already present) so it can be removed after a configurable window (suggest `TELL_ESCALATION_DURATION_TICKS = 300` = 30 game-minutes). Add a system to remove expired `RoutineDeviation` components.
**Why this is the right channel:** D-024 axis 9 (tell system) is a simulation output, not authored content. The `RoutineDeviation` tell fires when the NPC is off their usual schedule — which is exactly true post-activation (the triangle is hot). No new content required. Client sees it as `tell_state: RoutineDeviation` on the visible entity. Copy (#597) authors the monologue lines the client fires when the player observes this tell.
**Integration point:** `TriangleActivatedQueue` is in `server/src/storyteller/mod.rs`. The new system should live in `server/src/storyteller/` or `server/src/simulation/pressure.rs` — either is appropriate. Register in `StorytellerPlugin.build()`.
**Test:** `cargo test` — verify that after `TriangleActivatedQueue` is populated with a test event, triangle NPCs have `RoutineDeviation` inserted and `derive_tell_state()` returns `RoutineDeviation` for them.
---
### #591 — News ticker: server-side
**What exists:** `content/campaigns/main/systems/krenn/stations/sova/districts/transit/ticker/the-last-shift.yaml` — 30 authored headlines (freight, politics, infrastructure, sports, commission, community categories). The YAML is fully authored (Sprint 12, #306). It is NOT loaded at runtime — the content loader (`server/src/content/loader.rs`) does not parse ticker files. `ObserverSnapshotWire` in `server/src/bridge/types.rs` has no ticker field.
**What to deliver:**
1. `TickerLine` struct: `id: String`, `text: String`, `category: String`. Add to `bridge/types.rs`.
2. Add `current_ticker: Option<TickerLine>` to `ObserverSnapshotWire`. Only populated when player is in The Last Shift zone (zone_id `"bar"` — check `server/src/simulation/zone.rs`). `None` in all other zones.
3. `TickerPool` resource: loads `ticker/the-last-shift.yaml` at startup via the content loader. Holds all 30 headlines. Uses `SimRng` to advance to a new headline every `TICKER_ROTATION_TICKS = 200` ticks (20 game-minutes). Deterministic under D-010 — always use `SimRng`, never system randomness.
4. In the observer snapshot system (`server/src/perception/observer/mod.rs`), read `TickerPool` and populate `current_ticker` when player zone is `"bar"`.
**Gotcha:** The ticker rotation must use `SimRng` (the seeded bevy resource), not `rand::thread_rng()`. D-010 principle 4 — deterministic simulation. A ticker rotation driven by `thread_rng()` would produce different headlines on replay, breaking golden-file tests.
**Content note:** The ticker content file at `ticker/the-last-shift.yaml` uses a `dual_lens` field per headline (separate monologue notes for smuggler vs detective perspective). These notes are for copy authoring reference only — do NOT include them in the `TickerLine` wire struct. The `text` field is what crosses the boundary; `dual_lens` is authoring metadata.
---
### #595 — Opening monologue trigger
**What exists:** `server/src/simulation/monologue.rs` — `MonologueState` has `pub character: String` (initialized as `"detective"`) and `pub enter_location_fired: bool`. The system `tick_monologue()` calls `select_monologue_line()` which filters pools by `pool.character != character` — so the character string gate already works. Opening monologue content is fully authored at:
- `content/campaigns/main/.../monologue/detective/opening.yaml` (12 lines, Sprint 12 #300)
- `content/campaigns/main/.../monologue/smuggler/opening.yaml` (Sprint 12 #299)
Both files use `trigger: enter_location` and `situation: [arrival, shift_start]`. The `enter_location` trigger fires on the first tick (`MonologueState.enter_location_fired = false` → fires → sets `true`).
**What to deliver:** This ticket is small because the infrastructure is almost complete. The only missing piece is that `MonologueState.character` is initialized as `"detective"` before the archetype is known. Fix:
1. After #587 lands (archetype inserted on player entity), read `CharacterArchetype` in `MonologuePlugin.build()` or the first-tick setup system and set `MonologueState.character` correctly: `CharacterArchetype::Smuggler → "smuggler"`, `CharacterArchetype::Detective → "detective"`.
2. Verify that `select_monologue_line()` with `trigger = "enter_location"` and `character = "smuggler"` correctly selects from `opening.yaml` in the smuggler subdirectory. Run the existing monologue integration test with a smuggler archetype — it should already pass once #587 sets the character string.
3. Add a regression test: spawn two sessions (smuggler + detective), advance 1 tick each, assert different `character` on `MonologueState`. This verifies the archetype flows end-to-end.
**Blocked by:** #587 (needs `CharacterArchetype` on player entity before this system can read it).
---
### #594 — Tile data model design (D-record)
**What exists:** Current tile format is single characters (`F/W/V/R`) in string arrays in location YAML files. This cannot represent per-tile properties (door access lists, container contents, damage state, visual variant, sound properties, trigger zones). Epic #586 tracks this. This ticket produces the design only — no migration, no implementation.
**What to deliver:** A filed D-record in `decisions/architecture.md` via `tooling/db/decision claim D architecture "Tile data model — extensible per-tile properties"` before writing. The D-record must:
1. Survey what tile-level data the game needs across systems (doors, containers, damage, visual variants, trigger zones, material properties).
2. Choose between: (a) tile palette/registry (tiles are typed by ID, properties on the type), (b) per-tile property bags (each tile can have arbitrary key-value), (c) ECS-style tile components (tiles are entities), or (d) hybrid.
3. Specify the YAML authoring format (human-writable, survives merge conflicts), the loader contract (how the server parses it), and the runtime representation (what ECS queries use).
4. Estimate migration effort for the 5 existing location YAMLs.
**Scope:** This is design work only. No code changes. The D-record is the deliverable. Tyre should author it; Dudley reviews for implementation feasibility. File via the standard decision workflow (`decision claim` → edit `decisions/architecture.md` → commit with pre-commit hook running `decisions-sync`).
---
### #593 — v0.1 playthrough proof
**What exists:** By the time this ticket starts, all upstream tickets are merged: archetype in StartupMessage (#587), tell escalation (#589), ticker in snapshot (#591), opening monologue archetype-gated (#595), client character select (#588), client triangle consumer (#590), client ticker (#592), copy activation monologue lines (#597).
**What to deliver:** This is an integration proof ticket, not an implementation ticket. Deliverable is a written test plan execution + green CI.
1. **Full playthrough test (manual):** Boot server in production mode (no `--gauntlet`). Boot client in production mode (no `SR_TEST=1`). From main menu, click "New Game." Character select screen appears — select Smuggler. Game scene loads. Confirm: opening monologue fires (Smuggler voice). Walk to The Terminal. Observe Kael for ~3 game-minutes (check `EngagementRecord` via debug console `npc <id>`). Use debug console `contaminate` to skip to contamination phase. Wait for activation pass — `triangles` command shows one triangle in Active phase. Walk back to Kael — `tell_state` is `RoutineDeviation`. Proximity monologue fires (from copy #597). Walk to The Last Shift — news ticker visible. CI green.
2. **Automated integration test (server):** Add `test_v0_1_integration_playthrough` in `server/tests/`. Uses the existing test-client infrastructure (tooling/test-client): boot server, send `StartupMessage { world_seed: 12345, character_archetype: Smuggler }`, advance 1 tick, assert snapshot contains smuggler opening monologue, advance to tick 3000, send `SkipToContamination` debug command, advance 10 more ticks, assert `TriangleActivatedQueue` is non-empty in server state (via golden snapshot comparison).
3. **PR merge coordination:** Server #593 and client #588/#590/#592 must all be green on their respective branches before this ticket can be filed as done. The playthrough proof is the collective gate for the sprint.
**Gotcha — PROTOCOL_VERSION:** #587 bumps to 19. Client #588 must update `Protocol.PROTOCOL_VERSION` to 19 simultaneously. The version mismatch is a connection crash. Coordinate with Stig on merge timing.
---
## Dependency Chain
```
#587 (archetype in StartupMessage) ─────────────────────────────────────────────────┐
└→ #595 (opening monologue archetype-gated) │
└→ #588 (client: character select screen) ← client work │
↓
#589 (tell escalation on TriangleActivated) ─────────────────────────────────────── #593
└→ #590 (client: triangle consumer) ← client work (v0.1
playthrough
#591 (news ticker in snapshot) ─────────────────────────────────────────────────── proof)
└→ #592 (client: ticker HUD) ← client work │
│
#597 (copy: activation monologue lines) ← copy work ────────┘
#594 (tile data model D-record) ← standalone design track, no v0.1 dependency
```
Parallel server tracks: #587, #589, #591, #594 are all independent — start all simultaneously. #595 blocked on #587.
---
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(server): character archetype in StartupMessage and monologue gating" \
--description "body" --base main --head server
```
---
## Sprint Completion (Server Criteria)
1. `cargo test` green on server branch. All existing tests pass. New tests for archetype round-trip and tell escalation pass.
2. `StartupMessage` with `character_archetype: Smuggler` produces a smuggler player entity with `MonologueState.character = "smuggler"` on tick 1.
3. After `TriangleActivated` fires, triangle NPCs have `RoutineDeviation` inserted and `tell_state: RoutineDeviation` appears in the snapshot.
4. Snapshot contains `current_ticker` (non-null headline) when player is in `"bar"` zone.
5. Opening monologue (enter_location) fires on tick 1 with lines from the correct character pool.
6. Integration test `test_v0_1_integration_playthrough` passes end-to-end.
7. `PROTOCOL_VERSION = 19` — matches client.
+185
View File
@@ -0,0 +1,185 @@
//! Generate snapshot fixtures from the Gauntlet test world (D-030 full pipeline).
//!
//! Runs the full Gauntlet simulation pipeline — same code path as --test-mode —
//! then serializes ObserverSnapshots to both MessagePack (wire format) and JSON
//! (human-readable debug) for client-side visual testing.
//!
//! The .msgpack files are the actual wire-format bytes the server sends over IPC.
//! Client visual tests load them via Protocol.decode_snapshot() → GameState.apply_snapshot(),
//! exercising the exact same pipeline as the live game.
//!
//! The .json files are for human inspection only.
//!
//! Run with: cargo test --test gen_gauntlet_fixtures -- --ignored
//! Or: make fixtures-gauntlet
//!
//! Output: tests/fixtures/gauntlet/*.{msgpack,json} (relative to repo root)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::npc::NpcPlugin;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition};
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::SimulationPlugin;
use settled_reach_server::test_world;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
const SEED: u64 = 42;
const FIXTURE_DIR: &str = "../tests/fixtures/gauntlet";
/// Build a deterministic Gauntlet simulation app.
/// Identical to what the server runs in --test-mode.
fn build_gauntlet(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(&mut app);
app
}
/// Teleport the player to a specific position and facing.
/// Directly modifies ECS components — same effect as PlayerAction::Teleport
/// but without needing a target room action.
fn teleport_player(app: &mut App, pos: TilePosition, facing: Facing) {
let player = {
let mut query = app
.world_mut()
.query_filtered::<Entity, With<PlayerCharacter>>();
query.single(app.world()).expect("player entity must exist")
};
app.world_mut()
.entity_mut(player)
.insert((pos, facing));
}
/// Run N ticks, feeding inputs each tick, return the last snapshot.
fn run_ticks(app: &mut App, inputs: &[Vec<PlayerInput>]) -> ObserverSnapshot {
let mut last_snapshot: Option<ObserverSnapshot> = None;
for tick_inputs in inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
last_snapshot = Some(snapshot.clone());
}
}
last_snapshot.expect("no snapshot produced")
}
/// Recursively sort all object keys for deterministic JSON output.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
fn write_fixture(name: &str, snapshot: &ObserverSnapshot) {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR);
fs::create_dir_all(&dir).expect("create fixture dir");
// MessagePack — exact wire-format bytes the server sends over IPC.
// Client visual tests load these via Protocol.decode_snapshot().
let msgpack = rmp_serde::to_vec_named(snapshot).expect("serialize to MessagePack");
let msgpack_path = dir.join(format!("{}.msgpack", name));
fs::write(&msgpack_path, &msgpack).expect("write msgpack fixture");
// JSON — human-readable debug companion (not loaded by tests).
let value: Value = serde_json::to_value(snapshot).expect("serialize to JSON");
let sorted = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted).expect("format JSON") + "\n";
let json_path = dir.join(format!("{}.json", name));
fs::write(&json_path, &json).expect("write json fixture");
eprintln!(
"Wrote {} ({} bytes msgpack, {} bytes json, {} visible_tiles, {} entities)",
name,
msgpack.len(),
json.len(),
snapshot.visible_tiles.len(),
snapshot.entities.len(),
);
}
#[test]
#[ignore] // Run manually: cargo test --test gen_gauntlet_fixtures -- --ignored
fn generate_gauntlet_snapshot_fixtures() {
// --- Hub (default spawn position) ---
// Player at (50, 58) facing North — tests fog rendering at the starting location.
// This is the exact state a new player sees on connect.
{
let mut app = build_gauntlet(SEED);
// Run 3 idle ticks to stabilize (cognitive delay, vision cone init)
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_spawn", &snapshot);
}
// --- Fog Theater (observer position) ---
// Player at (56, 18) facing South — large open room with NPCs at varying distances.
// Tests visibility cone, fog layers, and distance-based fog rendering.
{
let mut app = build_gauntlet(SEED);
let fog_theater = test_world::constants::FOG_THEATER;
teleport_player(&mut app, fog_theater.observer, fog_theater.observer_facing);
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("fog_theater", &snapshot);
}
// --- Hub after movement (explored tiles + visible tiles differ) ---
// Player moves south from hub, creating a mix of explored-but-not-visible
// and currently-visible tiles — the exact fog boundary condition.
{
let mut app = build_gauntlet(SEED);
let inputs: Vec<Vec<PlayerInput>> = vec![
vec![],
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 2,
action: PlayerAction::MoveSouth,
}],
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveSouth,
}],
vec![], // idle — snapshot has explored + visible tiles that differ
];
let snapshot = run_ticks(&mut app, &inputs);
write_fixture("hub_after_movement", &snapshot);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+95
View File
@@ -25,6 +25,14 @@ MODE="golden" # golden | screenshot | movie | update
TARGET=""
INTERVAL=""
# Server state for live scenarios
SERVER_BIN=""
SERVER_PID=""
SERVER_PORT=""
# Cleanup server on exit
trap '[[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null || true' EXIT
# -- Parse args ----------------------------------------------------------------
while [[ $# -gt 0 ]]; do
@@ -102,12 +110,84 @@ xvfb_capture() {
fi
}
# -- Server lifecycle (live scenarios) -----------------------------------------
# Check if a scenario has "live": true in config
is_live_scenario() {
python3 -c "
import json, sys
c = json.load(open('${CONFIG}'))
s = c.get('scenarios', {}).get('${1}', {})
sys.exit(0 if s.get('live') else 1)
"
}
# Build server binary (once, cached)
ensure_server_built() {
if [[ -n "$SERVER_BIN" ]]; then return 0; fi
echo " Building server for live visual tests..."
(cd "$ROOT/server" && cargo build --bin settled-reach-server 2>&1) || {
echo "Error: server build failed" >&2
return 1
}
SERVER_BIN="$ROOT/server/target/debug/settled-reach-server"
}
# Start server with --test-mode --port 0, parse LISTENING:{port}
start_server() {
local stdout_log
stdout_log=$(mktemp)
"$SERVER_BIN" --test-mode --port 0 >"$stdout_log" 2>/dev/null &
SERVER_PID=$!
local attempts=0
while [[ $attempts -lt 150 ]]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo " Error: server exited unexpectedly" >&2
rm -f "$stdout_log"
SERVER_PID=""
return 1
fi
if grep -q "^LISTENING:" "$stdout_log" 2>/dev/null; then
SERVER_PORT=$(sed -n 's/^LISTENING://p' "$stdout_log")
rm -f "$stdout_log"
echo " Server started: pid=$SERVER_PID port=$SERVER_PORT"
return 0
fi
sleep 0.1
attempts=$((attempts + 1))
done
echo " Error: no LISTENING signal after 15s" >&2
kill "$SERVER_PID" 2>/dev/null || true
rm -f "$stdout_log"
SERVER_PID=""
return 1
}
# Stop server (called after each live capture; server may have exited on disconnect)
stop_server() {
if [[ -n "$SERVER_PID" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
SERVER_PORT=""
fi
}
# -- Screenshot mode -----------------------------------------------------------
if [[ "$MODE" == "screenshot" ]]; then
mkdir -p "$CACHE_DIR"
echo "Capturing scenario: $TARGET"
if is_live_scenario "$TARGET"; then
ensure_server_built || exit 2
start_server || exit 2
export SR_LIVE=1 SR_PORT="$SERVER_PORT"
fi
godot_capture --scenario "$TARGET" "$CACHE_DIR"
stop_server
unset SR_LIVE SR_PORT 2>/dev/null || true
PNG="$CACHE_DIR/$TARGET.png"
if [[ -f "$PNG" ]]; then
echo "Screenshot: $PNG ($(stat -c%s "$PNG" 2>/dev/null || stat -f%z "$PNG") bytes)"
@@ -162,12 +242,27 @@ for scenario in "${SCENARIOS[@]}"; do
TOTAL=$((TOTAL + 1))
echo "--- $scenario ---"
# Start server for live scenarios
IS_LIVE=false
if is_live_scenario "$scenario"; then
IS_LIVE=true
ensure_server_built || { FAILED=$((FAILED + 1)); continue; }
start_server || { FAILED=$((FAILED + 1)); continue; }
export SR_LIVE=1 SR_PORT="$SERVER_PORT"
fi
# Capture
set +e
CAPTURE_OUT=$(xvfb_capture --scenario "$scenario" "$CACHE_DIR" 2>&1)
CAPTURE_RC=$?
set -e
# Stop server after capture (server exits on client disconnect anyway)
if [[ "$IS_LIVE" == "true" ]]; then
unset SR_LIVE SR_PORT 2>/dev/null || true
stop_server
fi
CAPTURED="$CACHE_DIR/$scenario.png"
if [[ $CAPTURE_RC -ne 0 ]] || [[ ! -f "$CAPTURED" ]]; then
+21
View File
@@ -2,6 +2,7 @@
"resolution": [960, 540],
"settle_frames": 30,
"tolerance": 5,
"max_diff_pct": 0.5,
"golden_dir": "client/tests/golden/visual",
"scenarios": {
"fog_3state": {
@@ -47,6 +48,26 @@
"cursor_menu": {
"ticks": 3,
"description": "Cursor rendering over dialogue option"
},
"fog_live_replay": {
"ticks": 1,
"description": "Replay real server hub snapshot — full pipeline fog test",
"replay_snapshot": "tests/fixtures/gauntlet/hub_spawn.msgpack"
},
"fog_theater_replay": {
"ticks": 1,
"description": "Replay Fog Theater — large room with NPCs at varying distances",
"replay_snapshot": "tests/fixtures/gauntlet/fog_theater.msgpack"
},
"fog_boundary_replay": {
"ticks": 1,
"description": "Replay hub after movement — explored/visible tile boundary",
"replay_snapshot": "tests/fixtures/gauntlet/hub_after_movement.msgpack"
},
"fog_live_hub": {
"ticks": 10,
"live": true,
"description": "Live server: Hub spawn fog — real pipeline regression test"
}
},
"flows": {
+24 -7
View File
@@ -239,18 +239,22 @@ def compare(
# ---------------------------------------------------------------------------
def load_tolerance(config_path: Path | None) -> int:
"""Read tolerance from config JSON, return DEFAULT_TOLERANCE on failure."""
def load_config(config_path: Path | None) -> dict:
"""Read visual test config, return dict with tolerance and max_diff_pct."""
if config_path is None:
config_path = DEFAULT_CONFIG
defaults = {"tolerance": DEFAULT_TOLERANCE, "max_diff_pct": 0.0}
if not config_path.exists():
return DEFAULT_TOLERANCE
return defaults
try:
with open(config_path) as f:
data = json.load(f)
return int(data.get("tolerance", DEFAULT_TOLERANCE))
return {
"tolerance": int(data.get("tolerance", DEFAULT_TOLERANCE)),
"max_diff_pct": float(data.get("max_diff_pct", 0.0)),
}
except (json.JSONDecodeError, ValueError, OSError):
return DEFAULT_TOLERANCE
return defaults
# ---------------------------------------------------------------------------
@@ -270,6 +274,12 @@ def main() -> int:
default=None,
help="Per-channel pixel tolerance (default: from config or 5)",
)
parser.add_argument(
"--max-diff-pct",
type=float,
default=None,
help="Max allowed diff percentage (default: from config or 0.0)",
)
parser.add_argument(
"--diff-output",
default=None,
@@ -282,9 +292,11 @@ def main() -> int:
)
args = parser.parse_args()
# Resolve tolerance: CLI > config > fallback
# Resolve settings: CLI > config > fallback
config_path = Path(args.config) if args.config else None
tolerance = args.tolerance if args.tolerance is not None else load_tolerance(config_path)
cfg = load_config(config_path)
tolerance = args.tolerance if args.tolerance is not None else cfg["tolerance"]
max_diff_pct = args.max_diff_pct if args.max_diff_pct is not None else cfg["max_diff_pct"]
# Read images
try:
@@ -322,6 +334,11 @@ def main() -> int:
return 0
pct = diff_count / total * 100
if pct <= max_diff_pct:
print(f"PASS: {diff_count} of {total} pixels differ ({pct:.1f}%, within {max_diff_pct}% threshold)")
return 0
print(f"FAIL: {diff_count} of {total} pixels differ ({pct:.1f}%)")
if args.diff_output and diff_buf: