Compare commits

...
30 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 615c3a9c17 chore(meta): release v0.1.20
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:52:38 +01:00
jpmschweitzerandClaude Opus 4.6 c8a0f1b327 chore(db): backup database after sprint 20 merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:51:51 +01:00
jpmschweitzer 3c634a937d Merge remote-tracking branch 'origin/server' 2026-02-25 23:50:56 +01:00
jpmschweitzerandClaude Opus 4.6 4ef1157b79 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:35 +01:00
jpmschweitzerandClaude Opus 4.6 d808d95659 refactor(simulation): address PR #72 review suggestions
- Hoshe #3: replace O(n²) Vec scan in fallback NPC assignment with
  BTreeSet; prevent same NPC assigned to two roles in one triangle
- Hoshe #4: add From impls for RoleId, TriangleId, StableId, and
  TriangleCrisisEventWire — eliminate fragile .0 access on newtypes
- Hoshe #5: consolidate near-identical unit tests with integration
  counterparts — keep only unique tests in #[cfg(test)] module
- Tyre #3: replace O(N*M) scan in apply_resolve_triangle with
  BTreeMap<TriangleId, Entity> index for O(1) per-command lookup
- Tyre #4: document &mut World on generate_intra_template_triangles
- Observer snapshot uses TriangleCrisisEventWire::from instead of
  manual field mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:35:23 +01:00
jpmschweitzerandClaude Opus 4.6 3a555a2eeb fix(simulation): validate dangling with_role in TriangleDef constraints
TriangleDef.validate() now rejects relationship constraints where
with_role references a role not in the triangle's three roles.
Catches authoring errors at load time instead of silently producing
broken constraint data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:23 +01:00
jpmschweitzerandClaude Opus 4.6 1ea3d9c724 fix(simulation): persist TriangleState in SaveStateV1
Triangle phase and tension were silently lost on save/load. Now
serialized as triangle_states vec in SaveStateV1, sorted by
triangle_id for determinism (D-010). Dedicated triangle entities
are respawned on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:18 +01:00
jpmschweitzerandClaude Opus 4.6 9bc5925220 fix(simulation): wire triangle crisis event queue into observer snapshot
The TriangleCrisisEventQueue was populated by tick_triangle_escalation
but never drained into ObserverSnapshot — clients always saw an empty
vec despite protocol v16 advertising the field. Now drains the queue
each tick and converts to TriangleCrisisEventWire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:48:11 +01:00
jpmschweitzerandClaude Opus 4.6 d34fc970a7 feat(simulation): add triangle escalation integration tests
16 tests covering escalation timing, crisis event emission,
resolve command, D-087 seed-dependent timing, D-089 no-cascade,
and edge cases (dormant skip, saturation, idempotent resolve).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:27 +01:00
jpmschweitzerandClaude Opus 4.6 918dffc643 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:05 +01:00
jpmschweitzerandClaude Opus 4.6 89158f2f2a chore(simulation): regenerate msgpack fixtures for protocol v16
Updates all client-side msgpack test fixtures and server test
harnesses to include the new triangle_crisis_events field added
in protocol v16.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:32 +01:00
jpmschweitzerandClaude Opus 4.6 c436ae905f feat(simulation): wire triangle escalation and crisis events into protocol
Registers tick_triangle_escalation and apply_resolve_triangle systems
in SimulationPlugin. Adds TriangleCrisisEventWire to ObserverSnapshot
(protocol v16) for future client rendering of triangle crises (#250,
D-087). Observer emits empty vec by default; escalation system will
populate when triangles reach Active phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:21 +01:00
jpmschweitzerandClaude Opus 4.6 9eaca0b5a4 feat(simulation): persist template ownership and references in save state
Adds TemplateOwnership to NpcSaveState and TemplateReferenceMap to
SaveStateV1 so cross-template links survive save/load and tier
eviction (D-025, D-026). Both fields use serde(default) for backward
compatibility with existing saves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:13 +01:00
jpmschweitzerandClaude Opus 4.6 0e8ac56fc1 feat(simulation): add social site template schema types
Implements #163 (RoleSchema), #164 (SpaceSpec), #165 (TemplateOwnership
+ TemplateReferenceMap), #106 (TriangleDef), #107 (intra-template
triangle generation), and #250 (triangle escalation system) as the
foundational Tier 2 template system per D-025.

New content/template module with YAML-deserializable schema types,
ECS components for ownership/triangle state, escalation system
running on game-minute boundaries, and TriangleCrisisEvent emission.
Sample YAML templates at server/data/templates/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:06 +01:00
jpmschweitzerandClaude Opus 4.6 98100da0d0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:23 +01:00
jpmschweitzerandClaude Opus 4.6 de136fc1a5 refactor(client): unify duplicate YAML parsers into YamlParser (#560)
Extract shared YamlParser utility (client/scripts/util/yaml_parser.gd)
with parse() for nested typed dicts and parse_flat() for dotted-key
string format. UIStrings._parse_yaml() and ChecklistEvaluator's inline
parser both delegate to YamlParser, removing ~140 lines of duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:00:05 +01:00
jpmschweitzerandClaude Opus 4.6 964bb9c459 refactor(client): extract SnapshotEventRouter from main.gd (#559)
New SnapshotEventRouter class (46 lines) provides callable-based
snapshot dispatch via register(), register_always(), and dispatch().
main.gd _process() now calls _router.dispatch(snapshot) instead of
15+ inline if-has blocks. Handlers registered in _ready().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:56 +01:00
jpmschweitzerandClaude Opus 4.6 6f9c97ca03 refactor(client): decouple dialogue_box from GameState and AudioManager (#558)
Replace 3 direct GameState.dialogue_active mutations and all
AudioManager.apply_dip/clear_dip calls with signals:
dialogue_state_changed, audio_dip_requested, audio_dip_cleared.
dialogue_box.gd now has zero references to GameState or AudioManager.
main.gd wires coordinator handlers in _ready() (D-020).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:47 +01:00
jpmschweitzerandClaude Opus 4.6 c8de1a0629 refactor(client): make stationary_ticks and zone_id server-authoritative (#557)
apply_snapshot() now reads stationary_ticks and zone_id directly from
the server snapshot when present (D-020 compliance). Client-side
accumulation and tile lookup retained as deprecated fallbacks until
the server populates these fields. Protocol.gd extended with decode
paths and TODO markers for the server team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:59:38 +01:00
jpmschweitzerandClaude Opus 4.6 95a9b349a0 docs(docs): add district topology diagram
D2 source + PNG render showing all zone connections, access tiers,
z-levels, maintenance corridor routing, and dual entry vectors.
Vertical layout with color-coded access tiers per D-093.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:34 +01:00
jpmschweitzerandClaude Opus 4.6 e4a3e0ce60 docs(docs): add gate cluster spatial layout
Full tile-level layout doc matching Terminal and Bar format. 7 zones
across z=1 and z=2, sightline matrix, access tier map, NPC traffic
density, Triangle 5 narrative notes. Per D-093 gate cluster spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:08:22 +01:00
jpmschweitzerandClaude Opus 4.6 33e0c51218 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:09:11 +01:00
jpmschweitzerandClaude Opus 4.6 1b5825cf39 docs(docs): add generator architecture workshop brief
Workshop brief for ticket #562 covering the top-down district
generator pipeline (Cities Skylines model). Builds on D-094
chunk/block/district hierarchy. Targets Q-036 and Q-037 resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:40 +01:00
jpmschweitzerandClaude Opus 4.6 5c029cf6ea docs(docs): update sova station profile for horizon station lore
Corrects horizon gate description per D-095: gates are at the Krenn
Ring (800 AU orbital installation), not on Station Sova. Admin Hub
houses transit processing facility, not gate apertures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:35 +01:00
jpmschweitzerandClaude Opus 4.6 98d28b5a54 docs(discussions): archive workshop #153 station district layout
Three-round workshop producing D-093 (Sova Transit District spatial
layout), D-094 (chunk/block/district hierarchy), D-095 (horizon
stations and transport lore). Resolves Q-040, Q-041, Q-043, Q-044;
partially resolves Q-042. Establishes spatial hierarchy as
architectural precedent for Q-036 generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:08:29 +01:00
jpmschweitzerandClaude Opus 4.6 81b64b3cf7 chore(db): backup database after Sprint 19 close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:58:41 +01:00
jpmschweitzer 7d501c2c58 Merge remote-tracking branch 'origin/planning'
# Conflicts:
#	CHANGELOG.md
2026-02-25 16:58:30 +01:00
jpmschweitzerandClaude Opus 4.6 d676a8f41e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:29 +01:00
jpmschweitzerandClaude Opus 4.6 6bf7d342f3 chore(skills): add planning team ticket type to sprint-plan
Supports design discussion tickets that run on the planning branch
with a purpose-assembled agent panel. Includes Qatux (documenter)
and SI (project manager) for bookkeeping alongside domain agents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:16 +01:00
jpmschweitzerandClaude Opus 4.6 2175349c56 docs(sprints): add sprint 20 "Shape" briefings
Server (6 tickets): template role/spatial/ownership schemas, triangle
definition, generation, and escalation. Client (4 tickets): code quality
refactors from review pass. Planning (1 ticket): #153 station district
layout design discussion with purpose-assembled agent panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:52:11 +01:00
69 changed files with 5931 additions and 276 deletions
+30
View File
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
When writing briefings, name the assigned agents in the **Agents** line of each
file so the team knows who to spawn.
### Planning Team Tickets
Some tickets need **design discussion** before implementation can begin — tagged
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
questions. These run on the `planning` branch as structured discussions with
the user and a purpose-assembled agent panel.
**When to create a planning ticket:**
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
- Ticket blocks 2+ downstream tickets across different teams
- Open Q-NNN items that block sprint candidates
- Architectural decisions that need multi-domain input before implementation
**Planning briefing format** (differs from implementation briefings):
- **Agents line**: List agents by domain relevance, not fixed team roster.
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
records decisions, updates domain files) and SI (project manager — creates
follow-up tickets, updates sprint assignments).
- **Discussion rounds**: Structure the conversation into 2-3 rounds
(inventory → proposals → convergence)
- **Context section**: List all existing design docs, decisions, and related
tickets that participants must read before the discussion
- **Output specification**: What the discussion must produce — typically a
D-record in `decisions/`, possibly a design doc in `docs/design/`
- **Decision questions**: Specific questions the discussion must answer,
not open-ended exploration
## Workflow
### 1. Run sprint prepare
+33
View File
@@ -6,9 +6,42 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.20] — 2026-02-25
### Added
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
### Fixed
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
- Validate dangling with_role references in TriangleDef constraint validation
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
- Consolidate near-identical unit tests with integration counterparts
### Changed
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
## [v0.1.19] — 2026-02-25
### Added
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
+36 -18
View File
@@ -109,14 +109,17 @@ var medium_sound_events: Array = []
var close_sound_events: Array = []
# D-071 (#530): Consecutive ticks without player position change.
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
# Fallback: client-side accumulation (deprecated, remove when server populates field).
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
# D-020: Read directly from snapshot "zone_id" field.
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
# Empty string when zone_id field absent.
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
@@ -140,12 +143,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
# Compares current player_position against previous snapshot's position.
if player_position == _prev_player_position:
stationary_ticks += 1
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
# Prefer server-sent value; fall back to client-side accumulation until server populates.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
# D-020: direct field assignment from server-authoritative snapshot.
stationary_ticks = snapshot.stationary_ticks
else:
stationary_ticks = 0
# DEPRECATED fallback — client-side accumulation. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
# driving state on the client). Server tracks this in ListeningFocus component.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
@@ -295,16 +305,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
player_knowledge = snapshot.player_knowledge
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
# Prefer server-sent top-level value; fall back to client-side tile lookup until
# server populates top-level "zone_id" in ObserverSnapshot.
if snapshot.has("zone_id") and snapshot.zone_id is String:
# D-020: direct field assignment from server-authoritative snapshot.
current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
# identity on the client via tile iteration). Server sends zone_id per
# VisibleTile but not as a top-level snapshot field.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
+2 -40
View File
@@ -49,44 +49,6 @@ func reload() -> void:
## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var stack: Array = [] # [[indent, key], ...]
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
# Trailing comment without a value — treat as section header
if val.begins_with("#"):
val = ""
# Pop sections at same or deeper indent
while stack.size() > 0 and stack.back()[0] >= indent:
stack.pop_back()
if val.is_empty():
# Section header — push onto stack
stack.push_back([indent, key])
else:
# Leaf value — extract from quotes or strip inline comment
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
val = val.substr(1, end_quote - 1)
else:
val = val.substr(1)
else:
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
var dotted_key := ""
for entry in stack:
dotted_key += entry[1] + "."
dotted_key += key
strings[dotted_key] = val
return strings
return YamlParser.parse_flat(text)
+3 -105
View File
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
# -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
return parse_checklist_yaml(text)
## Delegates to YamlParser.parse() (#560).
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
return YamlParser.parse(text)
+86 -86
View File
@@ -33,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
@@ -65,11 +66,51 @@ func _ready() -> void:
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_propagate_insert_state)
_router.register_always(_update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_play_recognition_chimes)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state)
if time_display:
_router.register_always(time_display.update_from_state)
if journal_panel:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_play_close_sound_events)
_router.register_always(_update_zone)
_router.register_always(_update_listening_focus)
_router.register_always(_consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consume_monologue)
_router.register("current_dialogue", _consume_dialogue)
_router.register("conversation_events", _consume_conversation_events)
_router.register("conversation_ended", _consume_conversation_ended)
_router.register("dialogue_response", _consume_dialogue_response)
_router.register("save_result", _consume_save_result)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
@@ -89,92 +130,9 @@ func _process(delta: float) -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
var insert_state := GameState.insert_active
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
cursor_renderer.set_insert_active(insert_state)
if interaction_list and interaction_list.has_method("set_insert_active"):
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
if minimap and minimap.has_method("set_insert_active"):
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
if interaction_list and interaction_list.has_method("update_from_state"):
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
inventory_grid.update_from_state()
# D-053: Update stance indicator
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# D-059/D-060: Update fog entity visualization (#431)
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
_play_recognition_chimes()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #264: Update journal panel — auto-close on dialogue, refresh if open
if journal_panel and journal_panel.has_method("update_from_state"):
journal_panel.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
# D-018 #125: Play close-range sound events via positional 2D audio
_play_close_sound_events()
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
_update_zone()
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# #174: Show examine result if server sent one this tick (#242)
_consume_examine_result()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
# D-061: Show dialogue if server sent one this tick (#434)
_consume_dialogue()
# #535: Consume overheard conversation events and responses
_consume_conversation_events()
_consume_conversation_ended()
_consume_dialogue_response()
# #554: Show save/load result notification
_consume_save_result()
# #559: Dispatch snapshot to registered handlers (router pattern).
# Always-run handlers update child nodes; keyed handlers fire for present fields.
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
@@ -247,6 +205,32 @@ func _process(delta: float) -> void:
_pending_record_inputs.clear()
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
func _propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func _update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
@@ -456,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
})
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
func _on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
func _on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
func _on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
+20
View File
@@ -244,6 +244,24 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"error": raw_save.get("error"),
}
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
# When server populates this field, client-side accumulation fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var stationary_ticks: Variant = null
var raw_st: Variant = raw.get("stationary_ticks")
if raw_st != null:
stationary_ticks = int(raw_st)
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
# When server populates this, client-side tile iteration fallback in game_state.gd
# can be removed — apply_snapshot() should contain only direct field assignments.
var zone_id: Variant = null
var raw_zid: Variant = raw.get("zone_id")
if raw_zid is String:
zone_id = raw_zid
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
@@ -302,6 +320,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
"stationary_ticks": stationary_ticks,
"zone_id": zone_id,
}
+45
View File
@@ -0,0 +1,45 @@
class_name SnapshotEventRouter
## Routes snapshot fields to registered handlers (D-020, #559).
##
## Decouples main.gd from knowing which child node handles which snapshot field.
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
##
## Two handler types:
## - Keyed: called only when the snapshot contains a specific field.
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
##
## All handlers are zero-argument callables — they read from GameState directly.
## This preserves GameState as the single source of truth post-apply_snapshot().
## Keyed handlers: field_name → Array[Callable]
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
var _keyed: Dictionary = {} # String → Array[Callable]
## Always handlers: called every dispatch regardless of snapshot content.
var _always: Array[Callable] = []
## Register a handler for a specific snapshot field key.
## Handler is called (with no arguments) when snapshot.has(field) is true.
## Multiple handlers per field are supported — they run in registration order.
func register(field: String, handler: Callable) -> void:
if not _keyed.has(field):
_keyed[field] = []
_keyed[field].append(handler)
## Register a handler that runs every dispatch tick (not keyed to a field).
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
func register_always(handler: Callable) -> void:
_always.append(handler)
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
func dispatch(snapshot: Dictionary) -> void:
for handler in _always:
handler.call()
for field in _keyed:
if snapshot.has(field):
for handler in _keyed[field]:
handler.call()
+163
View File
@@ -0,0 +1,163 @@
class_name YamlParser
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
##
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
## (as UIStrings._parse_yaml() requires).
##
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
##
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
## Parse YAML text into a hierarchical Dictionary.
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
## Values are type-inferred: bool, int, float, or String.
static func parse(text: String) -> Dictionary:
var root: Dictionary = {}
# Stack: [{indent: int, key: String}] — path of open section headers
var stack: Array = []
# Array state
var current_array: Variant = null # Array being built, or null
var current_item: Variant = null # Dict being built for current array item, or null
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
for raw_line in text.split("\n"):
var stripped := raw_line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
var content: String = stripped.strip_edges()
# --- Array item (- key: value) ---
if content.begins_with("- "):
# First item: convert parent section's {} placeholder to []
if current_array == null and stack.size() > 0:
var parent := _node_at(root, stack, true)
var arr_key: String = stack.back()["key"]
var new_arr: Array = []
parent[arr_key] = new_arr
current_array = new_arr
array_parent_indent = stack.back()["indent"]
# Flush previous item and start a new one
if current_item != null:
current_array.append(current_item)
current_item = {}
var rest: String = content.substr(2).strip_edges()
var colon: int = rest.find(":")
if colon >= 0:
var k: String = rest.substr(0, colon).strip_edges()
var v: String = rest.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- Continuation line within current array item ---
if current_array != null and indent > array_parent_indent:
var colon: int = content.find(":")
if colon >= 0 and current_item != null:
var k: String = content.substr(0, colon).strip_edges()
var v: String = content.substr(colon + 1).strip_edges()
current_item[k] = _parse_value(v)
continue
# --- End of array (indent has returned to array level or above) ---
if current_array != null:
if current_item != null:
current_array.append(current_item)
current_item = null
current_array = null
array_parent_indent = -1
if stack.size() > 0:
stack.pop_back() # pop the array-owning key
# --- Regular key: value or section header ---
var colon: int = content.find(":")
if colon < 0:
continue
var key: String = content.substr(0, colon).strip_edges()
var val_str: String = content.substr(colon + 1).strip_edges()
# Pop sections at the same or deeper indent (we're back at a shallower level)
while stack.size() > 0 and stack.back()["indent"] >= indent:
stack.pop_back()
var node: Dictionary = _node_at(root, stack, false)
if val_str.is_empty() or val_str.begins_with("#"):
# Section header — create nested dict (may become Array if - items follow)
node[key] = {}
stack.push_back({"indent": indent, "key": key})
else:
node[key] = _parse_value(val_str)
# Flush the last array item if the file ended inside an array
if current_array != null and current_item != null:
current_array.append(current_item)
return root
## Convenience: parse text and flatten to dotted-key format in one call.
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
static func parse_flat(text: String) -> Dictionary:
return flatten(parse(text))
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
## {"a": {"b": "v"}} → {"a.b": "v"}
## Arrays are skipped — dotted-key format does not represent them.
## All values are converted to String (UIStrings stores display text, not typed data).
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
var result: Dictionary = {}
for k in d:
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
var v = d[k]
if v is Dictionary:
result.merge(flatten(v, full_key))
elif not v is Array:
result[full_key] = str(v)
return result
## Parse a single YAML value string into a typed GDScript value.
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comment outside quotes
if not val.begins_with("\""):
var comment_pos: int = val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string — extract content between quotes
if val.begins_with("\""):
var end_quote: int = val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true": return true
if val == "false": return false
# Float (must have decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
## Navigate root following the stack key path.
## parent=true: navigate one level less (returns the parent node, not the leaf).
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
var node: Dictionary = root
var depth: int = stack.size() - (1 if parent else 0)
for i in range(depth):
var k: String = stack[i]["key"]
if node.has(k) and node[k] is Dictionary:
node = node[k]
else:
break
return node
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74
View File
@@ -596,3 +596,77 @@ func test_is_dialogue_active_true_after_show_dialogue() -> void:
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(box.is_dialogue_active()).is_true()
box.queue_free()
# ---------------------------------------------------------------------------
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
# directly mutating GameState or calling AudioManager.
# ---------------------------------------------------------------------------
func test_dialogue_state_changed_emits_true_on_show() -> void:
## D-020: show_dialogue() must emit dialogue_state_changed(true).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has(true)).override_failure_message(
"dialogue_state_changed(true) must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_dialogue_state_changed_emits_false_on_hide() -> void:
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.dialogue_state_changed.connect(func(active): received.append(active))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(received.has(false)).override_failure_message(
"dialogue_state_changed(false) must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
var box := _make_dialogue_box()
if box == null: return
var received: Array = []
box.audio_dip_requested.connect(func(profile): received.append(profile))
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(received.has("dialogue")).override_failure_message(
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
).is_true()
box.queue_free()
func test_audio_dip_cleared_emits_on_hide() -> void:
## D-020: hide_dialogue() must emit audio_dip_cleared.
var box := _make_dialogue_box()
if box == null: return
var cleared := [false]
box.audio_dip_cleared.connect(func(): cleared[0] = true)
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
box.hide_dialogue()
assert_bool(cleared[0]).override_failure_message(
"audio_dip_cleared must be emitted on hide_dialogue"
).is_true()
box.queue_free()
func test_no_direct_game_state_mutation() -> void:
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
## After show_dialogue, GameState.dialogue_active should remain unchanged
## (only the coordinator updates it via signal handler).
var box := _make_dialogue_box()
if box == null: return
GameState.dialogue_active = false
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
assert_bool(GameState.dialogue_active).override_failure_message(
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
).is_false()
box.queue_free()
GameState.dialogue_active = false
+143
View File
@@ -0,0 +1,143 @@
## Sprint 20 #558: dialogue_box.gd decoupling tests.
##
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestDialogueSprint20
extends GdUnitTestSuite
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSprint20: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func before_test() -> void:
GameState.dialogue_active = false
func after_test() -> void:
GameState.dialogue_active = false
# -- dialogue_state_changed signal -------------------------------------------
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
).is_equal(true)
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
## Without a connected handler, GameState.dialogue_active must stay false.
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
GameState.dialogue_active = false
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
).is_false()
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
## hide_dialogue() must emit dialogue_state_changed(false).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
box.hide_dialogue()
assert_that(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
).is_equal(false)
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received_profile: Variant = null
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
box.show_dialogue("NPC", "Hello.", [])
assert_that(received_profile).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
).is_equal("dialogue")
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var dip_before := AudioManager.get_active_dip()
box.show_dialogue("NPC", "Hello.", [])
var dip_after := AudioManager.get_active_dip()
assert_str(dip_after).override_failure_message(
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
).is_equal(dip_before)
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var cleared := false
box.audio_dip_cleared.connect(func(): cleared = true)
box.hide_dialogue()
assert_bool(cleared).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared (#558)"
).is_true()
func test_audio_dip_cleared_count_on_conversation_end() -> void:
## Verify audio_dip_cleared fires when conversation ends.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var cleared_count := 0
box.audio_dip_cleared.connect(func(): cleared_count += 1)
box.show_dialogue("NPC", "Speak.", [])
box.hide_dialogue()
assert_int(cleared_count).override_failure_message(
"audio_dip_cleared must fire at least once when conversation ends (#558)"
).is_greater_equal(1)
# -- coordinator wiring verification -----------------------------------------
func test_signal_handler_wires_game_state() -> void:
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
## Verifies the coordinator pattern works end-to-end (D-020 #558).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
box.show_dialogue("NPC", "Hello.", [])
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
).is_true()
box.hide_dialogue()
assert_bool(GameState.dialogue_active).override_failure_message(
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
).is_false()
+63 -4
View File
@@ -19,6 +19,8 @@ func before_each() -> void:
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.insert_active = true
@@ -115,19 +117,49 @@ func test_apply_snapshot_inventory_absent_clears_list() -> void:
assert_that(GameState.player_inventory.size()).is_equal(0)
# -- Stationary tick counter (D-071) -------------------------------------
# -- Stationary ticks: server-authoritative (D-020/D-071) -----------------
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
func test_stationary_ticks_from_server_snapshot() -> void:
## D-020: When server sends stationary_ticks, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
assert_int(GameState.stationary_ticks).is_equal(42)
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
## D-020: Server value takes priority — client must not accumulate on top of it.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"stationary_ticks": 5,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
GameState.stationary_ticks = 0
GameState.apply_snapshot({"tick": 1, "entities": []})
# No crash; field retains a valid integer value.
assert_int(GameState.stationary_ticks).is_greater_equal(0)
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
## Deprecated fallback: client accumulates when server omits the field.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
GameState.apply_snapshot(snapshot) # first call: position changes from sentinel
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_resets_on_player_movement() -> void:
func test_stationary_ticks_fallback_resets_on_movement() -> void:
## Deprecated fallback: client resets on movement when server omits the field.
var s1 := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
@@ -143,6 +175,33 @@ func test_stationary_ticks_resets_on_player_movement() -> void:
assert_int(GameState.stationary_ticks).is_equal(0)
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
func test_zone_id_from_server_snapshot() -> void:
## D-020: When server sends top-level zone_id, client reads it directly.
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
func test_zone_id_server_value_overrides_tile_derivation() -> void:
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"}],
"zone_id": "zone_from_server",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
func test_zone_id_missing_field_degrades_gracefully() -> void:
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
GameState.current_zone_id = ""
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.current_zone_id).is_equal("")
# -- insert_active (OQ-07, #522) -----------------------------------------
func test_apply_snapshot_insert_active_false() -> void:
+164
View File
@@ -0,0 +1,164 @@
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
##
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
## for stationary_ticks and zone_id directly from the snapshot when present,
## and degrades gracefully when the server has not yet added these fields.
##
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
## existing behaviour. This file covers the new code paths added in Sprint 20.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameStateSprint20
extends GdUnitTestSuite
func before_each() -> void:
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.visibility_sectors = {}
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
func test_stationary_ticks_reads_server_value_when_present() -> void:
# When snapshot includes stationary_ticks, apply_snapshot() must use the
# server value directly without client-side accumulation (D-020).
var snapshot := {
"tick": 5,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 42,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
).is_equal(42)
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
# Server-sent value must be assigned directly — NOT added to existing value.
# Two calls with stationary_ticks=10 must yield 10, not 20.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 10,
}
GameState.apply_snapshot(snapshot)
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server value must be assigned directly, not accumulated (D-020)"
).is_equal(10)
func test_stationary_ticks_server_can_reset_to_zero() -> void:
# Server sends 0 when player moves — client must accept this reset.
GameState.stationary_ticks = 50
var snapshot := {
"tick": 2,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"stationary_ticks": 0,
}
GameState.apply_snapshot(snapshot)
assert_int(GameState.stationary_ticks).override_failure_message(
"Server reset to 0 must override client-held value"
).is_equal(0)
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
func test_stationary_ticks_fallback_when_field_absent() -> void:
# When snapshot has no stationary_ticks, client-side accumulation must still
# run (backward compat until server ships field). No crash.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
}
GameState.apply_snapshot(snapshot) # position changes from ZERO → resets to 0
GameState.apply_snapshot(snapshot) # position unchanged → increments
assert_int(GameState.stationary_ticks).override_failure_message(
"Client-side fallback must increment stationary_ticks when field absent"
).is_greater(0)
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
var snapshot := {"tick": 1, "entities": []}
# No assertion needed beyond confirming no exception is raised.
GameState.apply_snapshot(snapshot)
assert_bool(true).is_true()
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
func test_current_zone_id_reads_server_value_when_present() -> void:
# When snapshot includes top-level zone_id, apply_snapshot() must use it
# directly without tile lookup (D-020).
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_server_direct",
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
).is_equal("zone_server_direct")
func test_current_zone_id_server_value_overrides_tile_data() -> void:
# When snapshot has both zone_id and visible_tiles with a different zone,
# the top-level zone_id field takes priority.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"zone_id": "zone_from_server",
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Top-level zone_id must override tile-derived zone when both present"
).is_equal("zone_from_server")
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
# smoke test confirming the fallback path still works after Sprint 20 refactor.
var snapshot := {
"tick": 1,
"entities": [
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
"visible_tiles": [
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
],
}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).override_failure_message(
"Tile lookup fallback must populate zone_id when top-level field absent"
).is_equal("zone_tile_fallback")
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
# Snapshots lacking both zone_id and visible_tiles must not crash.
var snapshot := {"tick": 1, "entities": []}
GameState.apply_snapshot(snapshot)
assert_str(GameState.current_zone_id).is_equal("")
@@ -0,0 +1,89 @@
## SnapshotEventRouter unit tests (#559).
## Verifies callable-based dispatch: keyed handlers receive correct field values,
## always handlers run on every dispatch, absent fields don't trigger handlers.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestSnapshotEventRouter
extends GdUnitTestSuite
# -- Keyed handlers -----------------------------------------------------------
func test_keyed_handler_called_when_field_present() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"current_monologue": {"text": "Test."}})
assert_that(received.size()).is_equal(1)
assert_that(received[0]).is_equal("monologue")
func test_keyed_handler_not_called_when_field_absent() -> void:
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("monologue"))
router.dispatch({"tick": 1})
assert_that(received.size()).is_equal(0)
func test_multiple_keyed_handlers_same_field() -> void:
## Multiple handlers on the same key run in registration order.
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("save_result", func(): order.append("first"))
router.register("save_result", func(): order.append("second"))
router.dispatch({"save_result": {"success": true}})
assert_that(order).is_equal(["first", "second"])
func test_keyed_handlers_multiple_fields() -> void:
## Each keyed handler fires only for its registered field.
var router := SnapshotEventRouter.new()
var received: Array = []
router.register("current_monologue", func(): received.append("mono"))
router.register("current_dialogue", func(): received.append("dlg"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(received).is_equal(["mono"])
received.clear()
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
assert_bool(received.has("mono")).is_true()
assert_bool(received.has("dlg")).is_true()
# -- Always handlers ----------------------------------------------------------
func test_always_handler_called_on_every_dispatch() -> void:
var router := SnapshotEventRouter.new()
var count: Array[int] = [0]
router.register_always(func(): count[0] += 1)
router.dispatch({"tick": 1})
router.dispatch({"tick": 2})
router.dispatch({})
assert_int(count[0]).is_equal(3)
func test_always_handler_runs_before_keyed() -> void:
## Always handlers run before keyed handlers (dispatch order guarantee).
var router := SnapshotEventRouter.new()
var order: Array = []
router.register("current_monologue", func(): order.append("keyed"))
router.register_always(func(): order.append("always"))
router.dispatch({"current_monologue": {"text": "Hi."}})
assert_that(order[0]).is_equal("always")
assert_that(order[1]).is_equal("keyed")
# -- Empty dispatch ------------------------------------------------------------
func test_empty_snapshot_no_crash() -> void:
var router := SnapshotEventRouter.new()
router.register("foo", func(): pass)
router.register_always(func(): pass)
# Must not crash
router.dispatch({})
func test_no_handlers_no_crash() -> void:
var router := SnapshotEventRouter.new()
# Must not crash
router.dispatch({"tick": 1, "entities": []})
+261
View File
@@ -0,0 +1,261 @@
## YamlParser unit tests (#560).
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
## quoted strings, empty input, edge cases.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestYamlParser
extends GdUnitTestSuite
# -- parse(): basic key-value pairs -------------------------------------------
func test_parse_simple_key_value() -> void:
var result := YamlParser.parse("key: value")
assert_that(result["key"]).is_equal("value")
func test_parse_quoted_string() -> void:
var result := YamlParser.parse('key: "hello world"')
assert_that(result["key"]).is_equal("hello world")
func test_parse_empty_quoted_string() -> void:
var result := YamlParser.parse('key: ""')
assert_that(result["key"]).is_equal("")
func test_parse_integer_value() -> void:
var result := YamlParser.parse("count: 42")
assert_that(result["count"]).is_equal(42)
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
func test_parse_negative_integer() -> void:
var result := YamlParser.parse("offset: -3")
assert_that(result["offset"]).is_equal(-3)
func test_parse_float_value() -> void:
var result := YamlParser.parse("radius: 2.5")
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
func test_parse_boolean_true() -> void:
var result := YamlParser.parse("enabled: true")
assert_that(result["enabled"]).is_equal(true)
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
func test_parse_boolean_false() -> void:
var result := YamlParser.parse("enabled: false")
assert_that(result["enabled"]).is_equal(false)
func test_parse_empty_input() -> void:
var result := YamlParser.parse("")
assert_that(result.size()).is_equal(0)
func test_parse_only_comments() -> void:
var result := YamlParser.parse("# comment\n# another")
assert_that(result.size()).is_equal(0)
func test_parse_inline_comment_stripped() -> void:
var result := YamlParser.parse("room_id: test # this is a comment")
assert_that(result["room_id"]).is_equal("test")
func test_parse_quoted_value_with_hash() -> void:
## Quoted strings preserve literal # characters.
var result := YamlParser.parse('color: "#e0e8ff"')
assert_that(result["color"]).is_equal("#e0e8ff")
func test_parse_value_with_colon() -> void:
var result := YamlParser.parse('time: "12:30"')
assert_that(result["time"]).is_equal("12:30")
# -- parse(): nested maps -----------------------------------------------------
func test_parse_nested_map() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result.has("section")).is_true()
assert_that(result["section"] is Dictionary).is_true()
assert_that(result["section"]["key"]).is_equal("value")
func test_parse_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse(yaml)
assert_that(result["a"]["b"]["c"]).is_equal("deep")
func test_parse_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["mode"]).is_equal("Mode")
assert_that(result["interaction"]["talk"]).is_equal("Talk")
func test_parse_multiple_keys_per_section() -> void:
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
var result := YamlParser.parse(yaml)
assert_that(result["hud"]["a"]).is_equal(1)
assert_that(result["hud"]["b"]).is_equal(2)
assert_that(result["hud"]["c"]).is_equal(3)
func test_parse_sibling_subsections() -> void:
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["states"]["a"]["x"]).is_equal(1)
assert_that(result["states"]["b"]["x"]).is_equal(2)
func test_parse_section_with_trailing_comment() -> void:
## "section: # comment" should be treated as a section header.
var yaml := "section: # comment\n key: value"
var result := YamlParser.parse(yaml)
assert_that(result["section"]["key"]).is_equal("value")
# -- parse(): arrays of dicts -------------------------------------------------
func test_parse_single_array_item() -> void:
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
var result := YamlParser.parse(yaml)
assert_that(result.has("conditions")).is_true()
assert_that(result["conditions"] is Array).is_true()
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
assert_that(result["conditions"][0]["type"]).is_equal("near")
assert_that(result["conditions"][0]["x"]).is_equal(10)
func test_parse_multiple_array_items() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
assert_that(result["conditions"][0]["id"]).is_equal("a")
assert_that(result["conditions"][1]["id"]).is_equal("b")
func test_parse_array_items_with_blank_lines() -> void:
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
var result := YamlParser.parse(yaml)
assert_that(result["conditions"].size()).is_equal(2)
func test_parse_top_level_plus_array() -> void:
## Checklist format: top-level key-value pairs followed by a conditions array.
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("warehouse")
assert_that(result["conditions"].size()).is_equal(1)
assert_that(result["conditions"][0]["id"]).is_equal("c1")
func test_parse_array_typed_values() -> void:
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
var result := YamlParser.parse(yaml)
var item: Dictionary = result["items"][0]
assert_that(item["x"]).is_equal(42)
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
assert_that(item["active"]).is_equal(true)
# -- parse_flat(): dotted keys -------------------------------------------------
func test_flat_simple() -> void:
var yaml := "section:\n key: value"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("section.key")).is_true()
assert_that(result["section.key"]).is_equal("value")
func test_flat_deeply_nested() -> void:
var yaml := "a:\n b:\n c: deep"
var result := YamlParser.parse_flat(yaml)
assert_that(result["a.b.c"]).is_equal("deep")
func test_flat_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Mode")
assert_that(result["interaction.talk"]).is_equal("Talk")
func test_flat_values_are_strings() -> void:
## parse_flat returns all values as strings, unlike parse() which returns typed.
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.count"]).is_equal("42")
assert_that(result["section.rate"]).is_equal("0.9")
assert_that(result["section.active"]).is_equal("true")
func test_flat_quoted_value() -> void:
var yaml := 'section:\n key: "hello world"'
var result := YamlParser.parse_flat(yaml)
assert_that(result["section.key"]).is_equal("hello world")
func test_flat_inline_comment() -> void:
var yaml := "hud:\n mode: Standard # default"
var result := YamlParser.parse_flat(yaml)
assert_that(result["hud.mode"]).is_equal("Standard")
func test_flat_empty_quoted_value() -> void:
var yaml := 'hud:\n prefix: "" # No prefix'
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("hud.prefix")).is_true()
assert_that(result["hud.prefix"]).is_equal("")
func test_flat_skips_arrays() -> void:
## Arrays have no dotted-key representation — they are skipped in flat output.
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
var result := YamlParser.parse_flat(yaml)
assert_that(result.has("room_id")).is_true()
# No dotted keys for array contents
assert_that(result.has("conditions")).is_false()
assert_that(result.has("conditions.0")).is_false()
func test_flat_empty_input() -> void:
var result := YamlParser.parse_flat("")
assert_that(result.size()).is_equal(0)
# -- Dialogue theme format (regression) ----------------------------------------
func test_dialogue_theme_format() -> void:
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
var flat := YamlParser.parse_flat(yaml)
assert_that(flat["player_color"]).is_equal("#e0e8ff")
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
assert_that(flat["passive_opacity"]).is_equal("0.9")
# -- Checklist format (regression) ---------------------------------------------
func test_checklist_format() -> void:
## checklist.yaml: top-level kv + conditions array with typed values.
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("inventory_warehouse")
var cond: Dictionary = result["conditions"][0]
assert_that(cond["id"]).is_equal("test-1")
assert_that(cond["condition_type"]).is_equal("player_near")
assert_that(cond["x"]).is_equal(10)
assert_that(cond["y"]).is_equal(20)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
+17 -12
View File
@@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
signal unpause_requested # D-061: auto-unpause
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
signal dialogue_state_changed(active: bool)
signal audio_dip_requested(profile: String)
signal audio_dip_cleared
@onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
dialogue_state_changed.emit(true) # D-064: coordinator blocks movement
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-069: Dialogue dip — coordinator routes to AudioManager
audio_dip_requested.emit("dialogue")
# D-061: auto-pause — signal to main.gd for input recording (#507)
pause_requested.emit()
@@ -311,14 +316,14 @@ func _end_player_conversation() -> void:
entry.timestamp_msec = now
_log_dirty = true
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
audio_dip_cleared.emit()
# D-061: unpause — signal to main.gd for input recording (#507)
unpause_requested.emit()
# D-064: unblock movement immediately — log entries stay visible but don't block input.
GameState.dialogue_active = false
# D-064: unblock movement immediately — coordinator handles GameState update.
dialogue_state_changed.emit(false)
# If no entries remain, hide the panel with fade.
if _log_entries.is_empty():
@@ -331,11 +336,11 @@ func hide_dialogue() -> void:
_end_player_conversation()
return # _end_player_conversation may call hide_dialogue if log is empty
GameState.dialogue_active = false
dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
return _in_player_conversation
func has_active_entries() -> bool:
@@ -428,12 +433,12 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
AudioManager.apply_dip("confrontation")
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
option_selected.emit(response_id, text)
_end_player_conversation()
)
@@ -443,7 +448,7 @@ func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
_beat_tween = null
AudioManager.clear_dip()
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
# -- Log rendering --
+12 -1
View File
@@ -234,4 +234,15 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*18 decisions. Last updated: 2026-02-12 (D-088 added — retroactive filing from v0.1 Content Scoping Workshop)*
### D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes
- **Date:** 2026-02-25
- **Decision:** The spatial hierarchy for map generation and streaming is defined as follows. **Chunk** = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. **Block** = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. **District** = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
- **Rationale:** Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
- **Raised by:** Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
- **Dissent:** Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-012 (tile spec — amended), D-014 (v0.1 map spec — district bounding box superseded), D-066 (dual-scale grid), D-093 (Sova Transit District layout using this hierarchy), Q-036 (district generator)
---
*19 decisions. Last updated: 2026-02-25 (D-094 added — Station District Layout Workshop, Sprint 20)*
+23 -1
View File
@@ -227,4 +227,26 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
---
*19 decisions. Last updated: 2026-02-12 (D-090, D-092 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)*
### D-093: Sova Transit District — Spatial Layout and District Topology
- **Date:** 2026-02-25
- **Decision:** The Sova Transit District spatial layout is confirmed. Four social sites (D-025): Terminal (Sova Logistics Hub, 44×28 visual), Bar/Last Shift (28×22 + 6m east ext.), Gate Cluster (40×32 visual, 7 zones — see zone spec below), Sector 3 residential (Drin/Naia anchor, ~15×12 visual, adjacent maintenance spine). Transit platform (The Loop stop, ~12×8 visual, bar-side) is classified as an encounter node, not a social site. District bounding box: 256×256 visual tiles (4×4 blocks per D-094). Three investigation paths confirmed: Path A (pattern recognition via camera logs/manifest), Path B (physical traversal via acoustic gap at restricted storage door seal → maintenance hatch), Path C (institutional, via Commission inspector relationship). Zone palette (surface hex / fog tint): gate cluster #b8bec4 / #0a1222; terminal #7a8490 / #0d1520; bar #6b4018 / #200c04; maintenance #4e5054 / #101214. Gate cluster zone spec (Araminta): aperture chamber 8×4 (restricted); freight staging 24×8 (private); passenger arrival 12×8 (semi-public); freight customs 20×10 (semi-private, 35 lanes at 2vt/lane); pedestrian customs 12×10 (semi-public, 3 lanes at 1vt); gate concourse 40×8 (public); observation gallery z=2 32×10 (Commission-only). Corridor widths: maintenance 2vt; internal building 34vt; secondary public 4vt; transition 6vt; gate concourse 8vt; service alcoves 12vt. Z-level scheme: z=0 maintenance corridor (Era 1, no Meridian), z=1 all main structures, z=2 gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall (player on z=2 sees z=1 below; upward LOS blocked except at staircase). Gate cluster social triangle: operations manager + senior freight handler + Commission inspector. Invisible infrastructure principle (G-08): every ring location reads as mundane; criminal function visible only to those who know. G-11: detective enters via gate cluster (Commission arrival); workers enter via transit platform (bar-side).
- **Rationale:** Emerged from three-round workshop synthesis. Layout satisfies D-025 (social sites), D-036 (Sova Transit District), D-054 (tile movement), D-059 (fog zone palette), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice criteria). Chunk/district hierarchy establishes architectural precedent for Q-036 generator.
- **Raised by:** Full team — Gestalt (gameplay constraints), Miri (worldbuilding/lore), Araminta (visual/spatial), Tyre (technical), Paula (narrative), Ozzie (player experience). Compiled by Qatux.
- **Dissent:** Araminta preferred 32×32 visual chunk size (overruled by lead and team majority). No other dissent.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-025 (social sites), D-036 (Sova setting), D-054 (tile movement), D-059 (fog system), D-066 (dual-scale grid), D-094 (district hierarchy), D-095 (transport lore), Q-036 (generator), Q-040Q-044 (transport lore questions)
---
### D-095: Horizon Stations and Gate Infrastructure — Transport Lore
- **Date:** 2026-02-25
- **Decision:** Span gates are human-built structures with a single aperture enabling near-instantaneous transit. Operating schedule uses dual-use windows: freight (bulk of hours) and passenger (scheduled slots). Physical layout: aperture chamber → freight staging / passenger arrival → customs lanes → gate concourse. Horizon stations are alien-built installations (no identified builder species), self-maintaining, located at Oort-cloud distance, with 48 apertures per station. Per-system canonical name: "The Ring." Travel is sequential-hop only (A→B→C through intermediate systems; no direct long-range transit). Per-system access tiers vary (4 tiers — some systems allow single-hop to orbital customs; no direct planetary span gate). Station Sova's horizon gates are located at The Ring (Oort-cloud orbital); the Administrative Hub contains booking offices only (not the gates themselves — correction to prior station profile text). "The Loop" is Sova's internal tram network: 6 districts, 4-minute run from Residential Core to Transit District. Workers arrive at the transit platform (bar-side) and disperse to Terminal or bar.
- **Rationale:** Resolves transport lore questions Q-040, Q-041, Q-043, Q-044 raised during Workshop #153. Miri's Round 3 contribution. Horizon station as alien-built infrastructure adds worldbuilding depth without requiring a named builder species. Sequential-hop travel creates natural story hooks (layover locations, transit records, smuggling route complexity).
- **Raised by:** Miri (worldbuilding), confirmed by team.
- **Dissent:** None.
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
- **Cross-reference:** D-093 (gate cluster spatial layout), Q-040 (gate dual-use topology — resolved), Q-041 (horizon station model — resolved), Q-042 (intra-system transport — partially resolved), Q-043 (station internal transit — resolved), Q-044 (gate-train integration — resolved)
---
*21 decisions. Last updated: 2026-02-25 (D-093, D-095 added — Station District Layout Workshop, Sprint 20)*
+38 -1
View File
@@ -242,6 +242,43 @@ Tracked questions awaiting discussion or resolution.
- **Assigned to:** Tyre, Nigel
- **Source:** Wiki Review Workshop R4
### Q-040: Gate dual-use topology — freight and commuter on shared span gate infrastructure
- **Status:** Resolved → D-093 (gate cluster zone spec), D-095 (span gate dual-use windows)
- **Question:** System span gates serve both freight and commuter traffic (one gate per system). How does this work physically? Is it one gate aperture with scheduling (freight window vs. passenger window), or parallel lanes (separate apertures for freight and passenger flows)? What does the gate facility look like from the inside — a single large bay or divided infrastructure?
- **Layout implication:** Affects the gate cluster spatial design in the Transit District — the gate cluster must accommodate both freight staging and passenger throughflow, possibly at different times of day.
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Surfaced by lead correction to Miri's S-02 (commuter transit ≠ second external gate).
- **Cross-reference:** D-036 (Sova setting), Q-036 (district skeleton as generator output)
### Q-041: Interstellar travel mechanics — horizon stations and gate architecture
- **Status:** Resolved → D-095 (horizon stations: alien-built, 48 apertures, Oort-cloud, "The Ring"; sequential hop travel)
- **Question:** A system needs MORE than one horizon gate for multi-hop connectivity (one gate allows only 1:1 connections). Lead proposal (Round 3): **Horizon stations** — orbital installations at Oort-cloud distance, partially or wholly understood ancient alien technology, self-maintaining (Mass Effect relay/Citadel analog). Each horizon station holds a FIXED number of active and inactive horizon gates. Some systems may only have one hop to an orbital customs station with no direct planet-side span gate access. Remaining questions: How many gates per horizon station? What determines which gates are active vs. inactive? Is the travel instantaneous or traversal-based? What is "The Ring" (the orbital horizon station) like as a physical space?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Lead correction in Round 3: single-gate-per-system model insufficient for multi-hop travel; horizon station model proposed.
- **Cross-reference:** D-036 (Sova setting), Q-039 (gate topology generation), Q-040 (gate dual-use topology)
### Q-042: Intra-system transport networks — passenger vs. freight, vehicles and modes
- **Status:** Partially resolved → D-095 (span gates at star/planetary level; horizon stations at Oort distance). Intra-system hab-to-hab transit remains open.
- **Question:** How do people and goods move within a star system (between orbital stations, planetary surfaces, and other in-system facilities)? Are there two separate networks (passenger transport and freight transport) or one shared network? What are the vehicle types and transit modes? How does intra-system transit interact with the span gate at the system's hub station?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
- **Cross-reference:** D-036 (Sova setting), Q-043 (station internal transit)
### Q-043: Station internal transit — intra-station transport system between districts
- **Status:** Resolved → D-095 (The Loop: 6-district tram, 4-minute Residential Core → Transit District; transit platform is bar-side encounter node)
- **Question:** What is the intra-station transport system on Station Sova? How do workers commute between districts (e.g., Residential Core → Transit District)? Is it a train, tram, shuttle, or pressurised corridor? What is the travel time and frequency? Where does the transit stop sit within the Transit District — gate-cluster-adjacent (workers arrive near freight operations) or bar-side-adjacent (workers arrive near their social space)?
- **Layout implication for #153:** The Transit District must include an internal transit stop. Its position within the district affects NPC traffic patterns and the district entry topology. This is the active T-03b question for Round 2/3 of the Station District Layout Workshop.
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Arose from lead correction: commuter transit = internal station transit, not a second external gate.
- **Cross-reference:** D-036 (Sova setting), Q-042 (intra-system transport networks), S-02 revision
### Q-044: Gate-train integration — do transport vehicles use gates directly or transfer on each side
- **Status:** Resolved → D-093/D-095 (gates are pedestrian/cargo-only; passengers transfer via gate concourse → transition corridor → transit platform; no direct gate-to-tram connection)
- **Question:** If trains or shuttles are the intra-system or intra-station transit mode, do they use the span gate directly (a train enters the gate and exits at the destination, carriages and all)? Or are the gates pedestrian/cargo-only, requiring passengers and freight to transfer to separate transport on each side? What does this imply for gate terminal design — does it need platforms, or just processing space?
- **Assigned to:** Miri
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
- **Cross-reference:** Q-040 (gate dual-use topology), Q-043 (station internal transit)
---
*39 questions (7 resolved, 1 partially resolved, 31 open). Last updated: 2026-02-25 (Q-030 through Q-039 added — retroactive filings from Wiki Review Workshop and v0.1 Content Scoping Workshop)*
*44 questions (11 resolved, 2 partially resolved, 31 open). Last updated: 2026-02-25 (Q-040, Q-041, Q-043, Q-044 resolved; Q-042 partially resolved — Station District Layout Workshop #153 Round 3)*
Binary file not shown.
+3 -1
View File
@@ -144,7 +144,9 @@ NPCs may reference locations and entities beyond Sova Station. These are real bu
**Other Krenn System stations:** The Krenn System has two other smaller orbital facilities (mining support station and an administrative relay). They're referenced occasionally in news tickers and operational scheduling. Not relevant to v0.1.
**The horizon gate:** Sova Station has a connection to the Reach's horizon gate network — the interstellar transport infrastructure. The horizon gate terminal is in the Administrative Hub district, not the Transit District. Characters with legitimate need can book transit to other systems. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
**The Krenn Ring (horizon station):** The Krenn System's interstellar connection is the Krenn Ring — a horizon station at approximately 800 AU from the Krenn star, accessible by system vessel (~46 days from Station Sova). Horizon stations are ancient orbital installations of unknown origin, self-maintaining, each containing multiple gate apertures connecting to other star systems. The Krenn Ring is not on Station Sova; it is a separate installation in the outer system.
**The Administrative Hub's interstellar transit facility:** Sova Station's Administrative Hub district houses the transit processing facility for interstellar travel — customs clearance, booking offices, and the shuttle dock for vessels heading to the Krenn Ring. When NPCs or documents refer to "the horizon gate terminal," they mean this processing facility, not a gate aperture on the station itself. Characters with legitimate need book transit here, then travel by shuttle to the Krenn Ring to board. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
---
+316
View File
@@ -0,0 +1,316 @@
# Spatial Layout: Gate Cluster (Span Gate Processing Facility)
**Ticket:** #153
**Date:** 2026-02-25
**Author:** Araminta (Visual Designer)
**Status:** v0.1 — wireframe quality, unblocks copy and gate cluster NPC authoring
**Grid:** 1 cell = 1m visual tile (visual grammar §2.1). Simulation operates at 0.5m; each visual tile = 2×2 sim tiles.
**Map area:** 40m wide × 32m deep (40×32 visual tiles) + observation gallery on z=2
**Zone palette:** Cool institutional grey — Era 3 construction, Commission-grade maintenance (visual grammar §1.1)
---
## Spatial Character
The gate cluster is the newest structure in the Transit District. Era 3 construction: clean sightlines, uniform LED-white overhead lighting, minimal accumulated grime. Commission-monitored and Commission-maintained. Where the Terminal reads as institutional but worn, and the Bar as warm and accumulated, the gate cluster reads as **administered**. The architecture communicates that someone is watching.
The building is a funnel. The span gate aperture (~1520m diameter ring) determines the widest point; the passenger and freight flows narrow through processing stages; they emerge into the gate concourse, which opens outward as public space. The spatial logic is deliberate: volume at intake, compression through customs, expansion at public exit.
Dual-use scheduling is the spatial and operational premise (D-095): freight windows and passenger windows share the single aperture. The "flicker" (90-second mode transition between sequences) is legible to experienced travelers — different lighting cues on the aperture chamber walls mark which mode is active.
**G-11 entry note:** The detective arrives via this cluster from a Commission shuttle. Workers arrive via the transit platform on the bar side. These are structurally separated entry vectors. The gate cluster is the detective's first experience of the district.
---
## Floor Plan
### z=1 (Ground Floor)
```
N (span gate aperture — external, connects to The Ring)
|
1111111111222222222233333333334444444
1234567890123456789012345678901234567890
############[APERTURE RING]######### row 01 <- span gate ring (structural boundary)
# . . . APERTURE CHAMBER . . . # row 02
# . . . . . . . . . . . . . . # row 03 ACCESS: RESTRICTED
# . . . . . . . . . . . . . . # row 04 (airlock/transition zone)
########[D]##########[D]############ row 05 <- chamber exit doors (freight W, passenger E)
############################[D]###### row 06 <- freight staging north wall (east door = PAB)
# FREIGHT STAGING # PAB # row 07
# [FK][FK] [FK][FK] . # . # row 08 ACCESS: private (freight) / semi-public (PAB)
# [FK][FK] [FK][FK] . # . # row 09 PAB = Passenger Arrival Buffer
# [FK][FK] [FK][FK] . # . # row 10
# . . . . . . . . [CT][CT] # . # row 11 <- CT = cargo transporter dock points
# . . . . . . . . [CT][CT] # . # row 12 <- PAB merges south into customs at row 13
#####[D]####################[D]###### row 13 <- into customs lanes
###################[D]############### row 14 <- freight customs north entry
# FCL | FCL | FCL | FCL | FCL # row 15 ACCESS: semi-private (freight customs)
# [TS] | [TS] | [TS] | [TS] | [TS] # row 16 FCL = freight customs lane (5 lanes × 4vt)
# || | || | || | || | || # row 17 TS = terminal/scanner station per lane
# || | || | || | || | || # row 18 || = cargo lane (4vt wide, column breaks Q4)
# [P] | [P] | [P] | [P] | [P] # row 19 P = pillar/LOS anchor (4-tile interval)
# . . .|. . . |. . . |. . . |. . . # row 20 <- inspection floor
#######|#######[D]####[D]###|######## row 21 <- customs south wall; PCL entry
# PCL PCL PCL PCL PCL # row 22 ACCESS: semi-public (pedestrian customs)
# [TS] [TS] [TS] [TS] [TS] . . # row 23 PCL = pedestrian customs lanes (3 lanes × 2vt)
# [P] . . [P] . . [P] . . # row 24 <- queue markers + pillar anchors
# . . . . . . . . . . . . . . . # row 25
# . . . . . . . . . . . . . . . # row 26
############[D]####[D]############### row 27 <- customs south doors to concourse
#################################### row 28 <- concourse north wall
# . . [B] [B] . . [NT][NT] # row 29 ACCESS: public
# . . . . . . . . . . # row 30 B = bench, NT = news ticker
# . . [B] [B] . . . . . # row 31
# . . . . . . . . [D]SC # row 32 <- staircase (SC) east end; Commission entry
#################################### row 33 <- concourse south wall (district entry facade)
|
S (district interior — Terminal forecourt, transition corridor)
```
**Legend:**
```
# Wall (solid, blocks LOS and movement)
. Open walkable floor
[D] Doorway (traversable)
[APERTURE RING] Span gate ring structure (impassable during transit; open between sequences)
FCL Freight customs lane
PCL Pedestrian customs lane
[TS] Terminal/scanner station (customs clerk workstation)
[FK] Freight staging kiosk / forwarder terminal
[CT] Cargo transporter dock point (loading/unloading position)
[B] Bench (public seating)
[NT] News ticker display (wall-mounted)
[P] Structural pillar (LOS anchor, column break, gallery support above)
SC Staircase to z=2 observation gallery (east end of concourse)
PAB Passenger Arrival Buffer (east of freight staging, rows 0612)
```
---
### z=2 (Observation Gallery) — Commission-only
```
N
|
(above customs lanes — rows 1427 below)
1111111111222222222233333333334444
1234567890123456789012345678901234567
[GALLERY NORTH RAIL — partial glass/grating]
################################# <- gallery west and east walls
# . . . . GALLERY FLOOR . . . # Commission-only: pristine near-white
# [DK][DK] . . . [DK][DK] # DK = observation desk / surveillance kit
# . . . . . . . . . . . . . . # floor: #d4d8dc
# . . . . . . . . . . . . . . # walls: #e0e4e8
# [DK][DK] . . . [DK][DK] #
# . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . #
# . . . . . . . . . . . . . . #
################################# <- gallery south rail (partial glass/grating)
|
[SC] staircase descends to z=1 concourse east end
|
S
```
**Gallery dimensions:** 32m wide × 10m deep (32×10 visual tiles). Positioned above the customs lanes (z=1 rows 1427) and NOT above the concourse or staging zones.
**Cross-z LOS:** Gallery rail is transparent low wall (glass or metal grating). Observer on z=2 has LOS downward to z=1 customs lanes. Upward LOS from z=1 is blocked except at the staircase opening. Players cannot see gallery occupants from the customs floor unless standing at the staircase.
**Gallery floor is the customs ceiling** — approximately 4m structural clearance below.
---
## Zone Breakdown
### Zone 1 — Aperture Chamber (rows 0105)
**Dimensions:** 40×4 visual tiles
**Access tier:** Restricted (Commission control + gate authority; no public entry)
**Purpose:** The transition space between the span gate aperture and the main processing facility. All passengers and freight pass through here immediately after emerging from the span gate. The aperture ring is the physical gate structure — when a transit sequence is active, the ring glows with transit residue (lighting cue for mode). Between sequences, the ring is dark and cold.
**NPC traffic:** Gate authority staff (23 stationed here per sequence). Arrivals flow through continuously during an active sequence; zero traffic between sequences.
**LOS notes:** The chamber is enclosed. No LOS to any other zone except the two exit doors (row 05). Gate authority staff can observe the full chamber volume. No LOS from the staging zones into the chamber.
**Key feature:** The "flicker" — the 90-second mode transition between freight and passenger sequences — is physically visible here. Lighting shifts, personnel rotate, cargo equipment is cleared or staged. An observer in the gate concourse (south) can hear the mode change but not see it.
### Zone 2 — Freight Staging (rows 0613, west 24 tiles)
**Dimensions:** 24×8 visual tiles
**Access tier:** Private (authorized freight operators and customs personnel only)
**Purpose:** Where inbound freight is offloaded, registered, and staged for the customs inspection lanes. [FK] forwarder terminals are where freight agents log their manifest declarations before the cargo moves to the lanes. [CT] dock points are active during freight windows; they are dormant (low power, no staff) during passenger windows.
**NPC traffic:** Busy during freight windows. The operations manager (Triangle NPC) is typically here during active freight sequences — their role is coordinating flow from aperture to customs. Senior freight handlers work the dock points. Sparse during passenger windows.
**LOS notes:** Full LOS across the staging floor from the forwarder terminals. The freight customs entry door (row 13) is visible from the staging area. The PAB door (east) is visible but the PAB interior is not.
**Key feature:** The operations manager's position here — with LOS to the aperture chamber exits, the staging floor, and the customs entry — is the spatial expression of their authority. They see everything that comes in.
### Zone 3 — Passenger Arrival Buffer (rows 0612, east 12 tiles)
**Dimensions:** 12×8 visual tiles
**Access tier:** Semi-public (arriving passengers only; no unauthorized entry from district side)
**Purpose:** Where passengers emerging from the span gate are held in a staging queue before processing through pedestrian customs. Separate from freight staging — the physical separation is the architectural enforcement of D-095's dual-use windows. During a freight window, the PAB is closed; during a passenger window, it fills.
**NPC traffic:** Moderated by gate sequence. Full during a passenger window; empty between or during freight windows.
**LOS notes:** LOS within the PAB is full. No direct LOS from the district concourse into the PAB — the customs lanes form a visual barrier. A player in the concourse sees only the south face of the customs lane structure.
**Key feature:** The detective's entry experience begins here (G-11). Arriving via Commission shuttle during a passenger window, they queue briefly before being waved through customs (or escorted directly to the observation gallery — see staircase at z=1 east end).
### Zone 4 — Freight Customs Lanes (rows 1421, west 20 tiles)
**Dimensions:** 20×10 visual tiles (5 lanes × 4vt each, within a 20vt-wide zone, rows 1421)
**Access tier:** Semi-private (freight operators entering the district; customs clerk staff)
**Purpose:** Processing incoming freight through customs inspection. Five lanes, each 4 visual tiles wide, each staffed by a customs clerk at a [TS] terminal station. [P] pillars at 4-tile intervals serve dual purpose: LOS anchors for the customs floor and structural supports for the observation gallery above.
**NPC traffic:** Customs clerks (5, one per lane) are stationed here during freight windows. The Commission inspector (Triangle NPC) circulates among lanes — their social dynamic with the clerks is expressed spatially by where they position themselves during inspections. During passenger windows, lanes are closed (screens down, no staff).
**LOS notes:** Clear LOS along each lane from north wall to south wall. The pillar breaks ([P]) interrupt cross-lane LOS at 4-tile intervals — an observer cannot see continuously across all 5 lanes. The gallery rail above (z=2) allows the Commission inspector to observe all lanes simultaneously from elevation. This is the critical asymmetry: floor-level observers have partial LOS; gallery observers have full LOS.
**Observation note:** The social triangle's power dynamic is visible in sightlines. The Commission inspector from the gallery sees the customs clerks in their entirety, including which freight forwarders are waved through vs. searched. The floor-level operations manager sees individual lanes but not the full picture. The detective, arriving from the gallery, can observe the customs floor before descending.
### Zone 5 — Pedestrian Customs Lanes (rows 2227, east 12 tiles)
**Dimensions:** 12×10 visual tiles (3 lanes × 2vt each, with queue space to east, rows 2227)
**Access tier:** Semi-public (arriving passengers processing into the district)
**Purpose:** Processing arriving passengers through customs. Three lanes, each 2 visual tiles wide, each with a [TS] scanner station. Queue space runs east of the lane structure. Simpler operation than freight customs — personal items scan, biometric check, manifest tag if applicable.
**NPC traffic:** Active only during passenger windows. During freight windows, the customs clerks from PCL rotate to assist with FCL overflow. The Commission inspector may operate from PCL during passenger windows if intelligence suggests surveillance value.
**LOS notes:** Narrower lanes mean LOS is more constrained. An observer in the queue can see only the lane directly ahead. From the gate concourse (south), the south face of the customs structure presents as a low partition — passengers emerging from customs are visible from the concourse immediately on exit.
**Key feature:** This is where observable inequity happens. The Commission inspector (or a directive they issue) results in one class of traveler being waved through while another is searched. This is visible to anyone in the concourse queue area — including the detective. The spatial proximity of the PCL south wall to the concourse benches [B] means concourse passengers witness the processing of arrivals.
### Zone 6 — Gate Concourse (rows 2833)
**Dimensions:** 40×8 visual tiles (full building width)
**Access tier:** Public (all district residents, workers, and new arrivals)
**Purpose:** The public-facing terminus of the gate cluster. Benches [B] for waiting passengers, news ticker [NT] on the east wall for transit schedules and general news, and the primary facade opening to the district south. The staircase (SC) at the east end is the access point to the observation gallery — it is Commission-coded at the base (a discreet panel, not a visible barrier).
**NPC traffic:** Variable. Busy when a passenger sequence has just completed (arrivals dispersing). Sparse during freight windows (only workers and officials). The concourse is the natural convergence zone for all district-side personnel who have business at the gate cluster.
**LOS notes:** Full east-west LOS across the concourse. The [P] pillars from the customs lanes above do not extend to the concourse floor — the south edge of the customs structure is a visual wall at row 27. From the benches, observers can see the customs exit doors (row 27) and watch arrivals emerge. Cannot see into customs lanes from bench positions.
**Key feature:** The social reading zone on arrival. New arrivals (including the detective on their first visit) experience the concourse before moving into the district. The news ticker is a topic generator. The Commission staircase (east end) is present but low-key — coded access does not broadcast itself in Commission-grade facilities.
### Zone 7 — Observation Gallery (z=2, above zones 45)
**Dimensions:** 32×10 visual tiles (above the full customs lane section)
**Access tier:** Commission-only (staircase coded at z=1 east end of concourse)
**Purpose:** The gallery is where the Commission inspector works during active processing sequences. From here, all freight and pedestrian customs lanes are simultaneously observable. Observation desks [DK] with surveillance kit allow real-time customs monitoring, camera feed access, and communication with gate authority staff below. This is the institutional oversight position — the spatial embodiment of Commission authority over district entry.
**NPC traffic:** The Commission inspector during work hours. Possibly a second Commission observer (junior) — but sparse. This is not a social space; it is a surveillance position.
**LOS notes:** Full LOS down to all customs lanes (z=2 → z=1, through gallery rail). Partial LOS to freight staging (row 13 door visible from gallery north rail). NO LOS to aperture chamber (wall blocks), NO LOS to concourse (gallery south rail is opaque below rail height). Gallery interior has no LOS from the customs floor below — the cross-z asymmetry is deliberate and complete.
**Key feature:** The detective's first introduction to this space is via escort through the staircase. The experience of descending from gallery (full picture) to concourse (partial picture) is the spatial tutorial for the information asymmetry theme.
---
## Key Observation Positions
| Position | Code | LOS coverage | Why it matters |
|----------|------|-------------|----------------|
| Observation gallery (z=2, center) | POS-G1 | All freight + pedestrian customs lanes simultaneously | Commission inspector's domain. Highest-information position in the gate cluster. Asymmetric — not visible from below. |
| Freight staging floor (center) | POS-G2 | Aperture chamber exits, freight staging, customs entry door | Operations manager's natural position. Sees intake and output but not gallery or pedestrian lanes. |
| Gate concourse benches (rows 29-30, west) | POS-G3 | Customs exit doors (row 27), staircase base (east), full concourse | Player's first investigation position. Passive observation of arrivals emerging from customs and who accesses the staircase. |
| Pedestrian customs queue (row 22, east) | POS-G4 | PCL lanes, customs exit direction | Observer in queue can watch the customs clerks processing arrivals. Visible inequity in who is waved through vs. searched. |
| Concourse east end (near staircase) | POS-G5 | Staircase access panel, anyone ascending/descending | Monitoring staircase access reveals Commission movement. Coded panel is discreet but observable. |
| Gallery north rail (z=2) | POS-G6 | Freight staging floor through rail, customs lane north entries | Extended north-viewing position from gallery — tracks cargo from aperture exit to lane entry. |
---
## Sightline Analysis
```
FROM → Aperture Frt.Stg. PAB Frt.Cust. Ped.Cust. Concourse Gallery
TO ↓
Aperture SELF via door via door NO NO NO NO
Frt.Staging via door SELF via door via door NO NO NO
PAB via door via door SELF NO NO NO NO
Frt.Customs NO via door NO SELF NO via door rail(z2→z1)
Ped.Customs NO NO NO NO SELF via door rail(z2→z1)
Concourse NO NO NO via door via door SELF NO
Gallery NO rail(N) NO rail(full) rail(full) NO SELF
rail(z2→z1) = LOS from gallery down through transparent rail/grating
rail(N) = gallery north rail has partial LOS to freight staging floor
NO = wall or z-gap blocks
via door = LOS when door open
```
**Critical sightline: Gallery → all customs lanes**
The Commission inspector on z=2 has full LOS over every freight and pedestrian customs lane simultaneously. No position on the z=1 customs floor achieves equivalent coverage. This asymmetry is the spatial expression of institutional oversight.
**Critical sightline gap: Concourse → customs interior**
The concourse benches are south of the customs structure. The customs south wall (rows 1421 for freight, 2227 for pedestrian) presents as a visual barrier. A player on the benches sees the customs exit doors and emerging arrivals — but not what happens inside the lanes. Investigation of customs behavior requires entering the lanes or reaching the gallery.
**Critical sightline gap: Gallery → concourse**
The gallery south rail is opaque below the rail height. The Commission inspector cannot observe the concourse from the gallery without descending. The gallery is a surveillance position for entry processing, not for the public space.
---
## Access Tier Map
```
RESTRICTED PRIVATE SEMI-PRIVATE SEMI-PUBLIC PUBLIC
────────── ─────── ──────────── ─────────── ──────
Aperture Freight staging Freight customs Ped. customs Concourse
chamber (auth. operators) lanes lanes (all)
(clerks + (arriving
[Gallery z=2: freight ops) passengers)
Commission-only]
```
Sequential access enforcement (H-06): A freight operator moving from aperture to district must pass through freight staging → freight customs → concourse. No spatial path skips a tier. Pedestrian arrivals pass through PAB → pedestrian customs → concourse. The two flows are physically separated (west half vs. east half of the building) and join only at the concourse.
---
## NPC Traffic Density Annotations
| Time | Aperture | Frt. Staging | PAB | Frt. Customs | Ped. Customs | Concourse | Gallery |
|------|----------|-------------|-----|-------------|-------------|-----------|---------|
| Dawn (05-07) | very sparse | very sparse | closed | closed | closed | very sparse | — |
| Freight window 1 (07-12) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
| Passenger window (12-14) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
| Freight window 2 (14-19) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
| Passenger window (19-20) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
| Evening sparse (20-23) | sparse | sparse | closed | sparse | closed | sparse | varies |
| Night (23-05) | very sparse | very sparse | closed | closed | closed | very sparse | — |
**Flicker windows:** 90-second transition between freight and passenger modes. During this window:
- Aperture chamber resets (personnel exchange, lighting shifts, cargo equipment cleared or staged)
- All customs lanes briefly closed
- Concourse becomes transiently busier as travelers waiting for mode completion gather
- The operations manager is most exposed — coordinating the reset, moving between staging and customs entry
**Detective entry:** Commission shuttles arrive during passenger windows as a matter of protocol. First contact with the district begins in the aperture chamber, proceeds to the PAB, and typically diverts to the gallery staircase before customs processing is required.
---
## Narrative Triangle Service Notes
### Triangle 5 — Gate Authority (Operations Manager Senior Freight Handler Commission Inspector)
This is an institutional-oversight triangle, structurally different from the Terminal's knowledge-and-leverage triangles (12) and the Bar's social-loyalty triangles (34).
- **Operations manager:** Their domain is the freight flow — aperture to staging to customs. They have private-tier access everywhere on the z=1 floor. They are measured by throughput: how much cargo clears customs in a window. They have an accommodation relationship with certain freight forwarders (see customs inequity below).
- **Senior freight handler:** The forwarder who benefits from that accommodation. They know what they get, they know why, and they know the operations manager knows they know. This is the stable complicity leg of the triangle.
- **Commission inspector:** Their domain is the gallery. They watch the customs floor from above. They may know about the accommodation, or may be about to discover it, or may be using it as leverage already. Their relationship to the operations manager is formally collaborative, actually adversarial.
**Spatial expression:** The operations manager never goes to the gallery. The Commission inspector rarely comes to the floor. The senior freight handler is on the floor. The triangle's tension is mediated by the cross-z sightline — the inspector can see the forwarder being waved through, and the operations manager knows the inspector is watching, but neither will acknowledge it in the same zone at the same time.
**Observable inequity (investigation entry point):** A player watching from the concourse benches (POS-G3) or from the pedestrian customs queue (POS-G4) can observe a freight forwarder being waved through freight customs without search while a commuter on the pedestrian side receives a full scan. This is not dramatic — it reads as normal. The player has to make the connection: waved through = known cargo = manifested incorrectly = this is where the lattice components enter.
**Tension staging locations:**
- Freight staging floor (operations manager's ground; conversations here are authority-neutral)
- Gallery (inspector's ground; a summons to the gallery is pressure)
- Customs lane (the observable action space; what clerks actually do is determined by unspoken directives from above)
- Concourse east end near staircase (the transition space; anyone ascending the staircase must pass anyone watching the staircase)
---
## Z-Level Notes
**z=0:** Not present in the gate cluster. Maintenance access to the gate cluster, if any, is via the district maintenance spine (z=0 elsewhere in district) and does not extend into the gate cluster interior. Era 3 construction has no maintenance corridor integration — maintenance occurs from above via service panels.
**z=1:** All gate cluster zones: aperture chamber, freight staging, PAB, freight customs, pedestrian customs, concourse. All NPCs and player movement on this level.
**z=2:** Observation gallery only. Staircase is the sole connection point (z=1 concourse east end ↔ z=2 gallery). Commission-coded access panel at base of staircase is present but low-profile (no visible lock, no visible panel labeling in Era 3 style — access is granted by insertion of Commission neural-tag proximity, not a key).
**Cross-z visibility rules:**
- Gallery → customs lanes: full downward LOS through transparent rail/grating
- Customs lanes → gallery: no upward LOS (gallery floor solid except rail; rail height above standing head height)
- Staircase opening: local LOS only (see who is at the staircase base or top, not into gallery interior)
---
## Notes for Copy Team
1. **Aperture chamber is the in-world ritual.** Arriving via span gate is Commonwealth-mundane but the aperture ring has residual energy effects — ambient hum, slight color temperature shift as light normalizes from transit. Monologue lines for the detective's arrival should note this. Standard sensory detail for immersive-world arrivals.
2. **The customs inequity is not dramatic.** When the senior freight handler is waved through, it should read as routine from NPC behavior — a nod, a scan confirmed, the lane opens. Overheard dialogue, if any, should be procedural: manifest check language, not conversational. The player learns that something is wrong from the pattern, not from a flagrant scene.
3. **The Commission inspector's gallery is their professional comfort zone.** Dialogue set in the gallery (if the detective accesses it) should reflect this — the inspector is at ease up here, slightly less guarded. On the floor, they are performing authority. In the gallery, they are just watching.
4. **The operations manager on the freight staging floor.** This is their element. Logistics language, shorthand with the senior freight handler. Any casual conversation with the detective here is the operations manager on home turf — helpful enough, not forthcoming.
5. **The flicker is an ambient event.** Travelers who know the schedule stop and wait. Travelers who don't know find themselves in a 90-second limbo — nothing is moving, customs is closed. The concourse fills briefly. Use this as a social compression beat: forced proximity, idle waiting, overheard conversations that wouldn't happen mid-flow.
6. **The staircase is visible, not obvious.** In Era 3 design language, it is clean, architectural, slightly more refined than the surrounding fittings. It doesn't broadcast Commission. Players who are paying attention will notice it; players who aren't will miss it. Second visit = "wait, I didn't see that last time."
+235
View File
@@ -0,0 +1,235 @@
# District Topology — Sova Transit District
# D-093 spatial layout. D-094 hierarchy: 256×256 vt (4×4 blocks).
# North = span gate entry. South-east = tram entry.
direction: down
vars: {
bg: "#1a1e24"
txt: "#c8d0e0"
acc: "#c8d8f0"
pub: "#1a3320"
spub: "#2e2a10"
spriv: "#2e1a08"
priv: "#2a0e0e"
comm: "#0e1a2e"
neut: "#1e2228"
s-pub: "#3a8a50"
s-spub: "#b8a020"
s-spriv: "#c86010"
s-priv: "#c02020"
s-comm: "#3060c0"
s-neut: "#4a5060"
}
# ── LEGEND ──
legend: Legend {
style.fill: ${bg}
style.stroke: ${acc}
style.font-color: ${txt}
style.font-size: 11
direction: right
l1: Public { style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt} }
l2: Semi-pub { style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt} }
l3: Semi-priv { style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt} }
l4: Private { style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt} }
l5: Commission { style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt} }
}
# ── NORTH ENTRY: SPAN GATE ──
span_gate: The Ring\n(span gate aperture) {
shape: hexagon
style.fill: ${comm}
style.stroke: ${s-comm}
style.font-color: ${txt}
}
# ── GATE CLUSTER ──
gate: Gate Cluster · 40×32 vt {
style.fill: ${bg}
style.stroke: ${s-comm}
style.font-color: ${txt}
style.border-radius: 4
aperture: Aperture\n8×4 {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
staging: Freight Staging\n24×8 {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
pab: Passenger Arrival\n12×8 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
customs: Customs Lanes\n(freight 5×4vt + ped 3×2vt) {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
concourse: Concourse\n40×8 · PUBLIC {
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
}
gallery: Gallery · z=2\n32×10 · COMMISSION {
style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt}
}
aperture -> staging: freight { style.stroke: ${s-priv} }
aperture -> pab: passenger { style.stroke: ${s-spub} }
staging -> customs { style.stroke: ${s-spriv} }
pab -> customs { style.stroke: ${s-spub} }
customs -> concourse { style.stroke: ${s-pub} }
concourse -> gallery: "staircase (Commission)" {
style.stroke: ${s-comm}; style.stroke-dash: 4
}
gallery -> customs: "LOS z=2 down" {
style.stroke: ${s-comm}; style.stroke-dash: 4
}
}
span_gate -> gate.aperture: "dual-use transit\n(90s flicker)" {
style.stroke: ${s-comm}
}
# ── TERMINAL ──
terminal: Terminal · 44×28 vt {
style.fill: ${bg}
style.stroke: ${s-spriv}
style.font-color: ${txt}
style.border-radius: 4
forecourt: Forecourt\n44×4 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
cargo: Cargo Floor + Main Corridor {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
storage: Restricted Storage\n(single coded door) {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
hatch_t: M-HATCH-T {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
forecourt -> cargo { style.stroke: ${s-spriv} }
cargo -> storage: "coded door" { style.stroke: ${s-priv} }
storage -> hatch_t { style.stroke: ${s-priv} }
}
gate.concourse -> terminal.forecourt: "district spine (south)" {
style.stroke: ${s-pub}
}
# ── TRANSITION CORRIDOR ──
corridor: Transition Corridor\n~40×6 vt {
style.fill: ${spub}
style.stroke: ${s-spub}
style.font-color: ${txt}
style.border-radius: 4
}
terminal.cargo -> corridor: "public route" {
style.stroke: ${s-spub}
}
# ── BAR ──
bar: The Last Shift · 28×22 vt {
style.fill: ${bg}
style.stroke: ${s-pub}
style.font-color: ${txt}
style.border-radius: 4
approach: Bar Approach\n28×3 {
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
}
floor: Main Floor\n(corner booth · card table) {
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
}
bathroom: Bathroom Corridor\n(east ext.) {
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
}
backroom: Back Room\n(Lera) {
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
hatch_b: M-HATCH-B {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
approach -> floor { style.stroke: ${s-pub} }
floor -> bathroom: "east door" { style.stroke: ${s-spriv} }
floor -> backroom: "staff only" { style.stroke: ${s-priv} }
bathroom -> hatch_b { style.stroke: ${s-priv} }
}
corridor -> bar.approach: "bar-side decompression" {
style.stroke: ${s-spub}
}
# ── MAINTENANCE CORRIDOR (z=0) ──
maint: Maintenance Corridor\n(z=0 · Era 1 · 2vt wide)\nzero public traffic {
style.fill: ${priv}
style.stroke: ${s-priv}
style.font-color: ${txt}
style.border-radius: 4
style.stroke-dash: 5
}
junc: JUNC-1 {
shape: diamond
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
}
terminal.hatch_t -> maint: "z=1 down z=0" {
style.stroke: ${s-priv}; style.stroke-dash: 5
}
maint -> junc { style.stroke: ${s-priv}; style.stroke-dash: 5 }
junc -> bar.hatch_b: "z=0 up z=1" {
style.stroke: ${s-priv}; style.stroke-dash: 5
}
# ── SOUTH-EAST ENTRY: TRAM ──
the_loop: The Loop\n(station tram · 6 districts) {
shape: hexagon
style.fill: ${neut}
style.stroke: ${s-neut}
style.font-color: ${txt}
}
platform: Transit Platform\n~12×8 vt · bar-side\n(encounter node) {
style.fill: ${pub}
style.stroke: ${s-pub}
style.font-color: ${txt}
style.border-radius: 4
}
the_loop -> platform: "workers arrive here (G-11)" {
style.stroke: ${s-neut}
}
platform -> bar.approach: "adjacent" {
style.stroke: ${s-pub}
}
# ── SECTOR 3 ──
sector3: Sector 3 Residential\n~15×12 vt · Drin/Naia {
style.fill: ${spub}
style.stroke: ${s-spub}
style.font-color: ${txt}
style.border-radius: 4
}
sector3 -> terminal.forecourt: "near Terminal" {
style.stroke: ${s-spub}; style.stroke-dash: 3
}
sector3 -> maint: "adjacent to spine" {
style.stroke: ${s-neut}; style.stroke-dash: 3
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

+1
View File
@@ -23,3 +23,4 @@ Historical discussion rounds from the Commonwealth game design process.
| 17 | Content Architecture | D-023, D-024, D-025, D-026, D-027, D-028, D-029 | [round-17](round-17-content-architecture.md) |
| 18 | v0.1 Gap Analysis Workshop | D-030, D-031, D-032, D-033, D-034, D-035, D-036, D-037, D-038, D-039, D-040 | [round-18](round-18-v01-gap-analysis-workshop.md) |
| 19 | Knowledge Graph & Information Boundaries Workshop | D-041, Q-016 resolved, Q-019 partially resolved, Q-024, Q-025, Q-026 | [workshop brief](../workshops/knowledge-graph-information-boundaries/workshop-brief.md), [synthesis](../workshops/knowledge-graph-information-boundaries/round2-synthesis.md) |
| 20 | Station District Layout Design (Workshop #153) | D-093, D-094, D-095; Q-040Q-044 resolved/partially resolved | [round-20](round-20-station-district-layout.md) |
@@ -0,0 +1,663 @@
# Round 20: Station District Layout Design — Ticket #153
**Sprint:** 20 (Shape)
**Date:** 2026-02-25
**Ticket:** #153 — Station district layout design
**Participants:** Gestalt, Miri, Araminta, Tyre, Paula, Ozzie, Qatux (documenter)
**Output target:** D-record in `decisions/content.md` or `decisions/architecture.md`
**Blocks:** #155, #188
---
## Internal Round Structure
| Internal Round | Topic | Status |
|----------------|-------|--------|
| Round 1 | Inventory and constraints | Complete — see §1 below |
| Round 2 | Topology proposals | Pending |
| Round 3 | Convergence and D-record draft | Pending |
---
## §1 — ROUND 1: Constraints Summary
*Compiled by Qatux. Derived from 6 agent contributions: Ozzie, Gestalt, Miri, Tyre, Araminta, Paula.*
**Lead note (pre-round):** The spatial patterns decided here establish the district template that Q-036's generator will eventually use. Decisions here become architectural precedent — not just v0.1 configuration.
**Qatux framing:** Constraints below are tagged:
- `[v0.1]` — specific to the hand-authored Sova Transit District
- `[template]` — generalisable to any future generated district of this type
- `[both]` — applies at both levels
Cross-reference: Q-036 (district skeleton as generator output) tracks where template decisions need formal specification.
---
### 1. Hard Constraints
*Non-negotiable: confirmed decisions, confirmed technical facts, Paula's structural requirements that block narrative arcs if violated.*
| # | Constraint | Source | Tag | Cross-ref |
|---|------------|--------|-----|-----------|
| H-01 | Dual-scale grid: 0.5m sim tiles, 1m visual tiles. All tile counts in this document are **visual tiles** unless noted. | D-066 | both | D-066 |
| H-02 | Tile-based movement. All spatial reasoning is discrete. Corridors must be ≥1 visual tile wide; functional spaces ≥2 tiles wide. | D-054 | both | D-054 |
| H-03 | Fog zone temperature tint is already decided: Terminal = cool dark, Bar = warm dark, corridors = neutral dark. The gate cluster adds a fourth zone requiring a tint assignment. | D-059 | v0.1 | D-059 |
| H-04 | Local map budget: ~150×150 tiles. Tyre confirms current layouts use ≤10% of this budget. Performance is not a binding constraint at current scope. | D-014, Tyre | both | D-014 |
| H-05 | Social sites must be connected spaces of 1540 tiles. | D-025 | template | D-025 |
| H-06 | Access tiers must be traversed **sequentially** — public → semi-public → restricted. No spatial path may allow skipping a tier. | Gestalt | template | D-025 |
| H-07 | Restricted storage has exactly **one** entrance (the coded door from the cargo floor). No second entrance, no back exit. | Paula | v0.1 | #311 |
| H-08 | Maintenance corridor has **zero LOS** from all other spaces. Its interior is not visible from any public or semi-public zone. Detection requires: sound propagation (footsteps on grating) or witness at hatch entry/exit points only. | Paula, smuggling layout | v0.1 | #313 |
| H-09 | No third route between Terminal and Bar. Exactly two routes exist: (a) the public transition corridor and (b) the maintenance corridor (ring-only, private). A third route would dissolve the ring's movement asymmetry. | Paula | v0.1 | #313 |
| H-10 | Bathroom corridor interior has **zero LOS** from the bar floor. Observer at bar can see only the door, not the interior. | Paula, bar layout | v0.1 | #312 |
| H-11 | No private path between manifest processing and supervisor's office. Maret's crossing of the main corridor is a public act with narrative cost — visibility is the spatial mechanism. | Paula, terminal layout | v0.1 | #311 |
| H-12 | Chunk size decision required. Tyre recommends **32×32 sim tiles** (= 16×16 visual tiles) as the generation unit. Zone and access-tier boundaries should align to chunk edges where possible. | Tyre | template | D-073, Q-036 |
| H-13 | Z-level allocation decision required. Tyre recommends maintenance corridor on **z=0**, bar/terminal structures on **z=1**. No additional z-levels without gameplay justification. | Tyre, Araminta | both | D-049 |
---
### 2. Setting Constraints
*What the Sova station profile and worldbuilding require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| S-01 | Span gate at Terminal's **north face**. Freight enters from north (inbound cargo from span gate); maintenance exits south. This is directional — north = external transit, south = district interior. | Miri, terminal layout | v0.1 |
| S-02 | **Two separate entry vectors** into the district: (a) freight span gate and (b) commuter transit connection. Passengers and freight do not share the same entry point. | Miri | template |
| S-03 | Sealed hull-section boundary. The district has a **finite, countable number of choke-point exits** — not open-ended. The player can know all exits. | Miri | template |
| S-04 | Sector 3 is a named sub-area of the district with an unresolved ventilation issue. Must be referenced spatially — it has a location, even if not a full social site. | Miri | v0.1 |
| S-05 | Maintenance spine is **Era 1 construction** — it predates the current buildings. The spine's route is fixed; buildings were placed around it. This explains: grating floors, cold-white sparse lighting, no Meridian coverage, and why the ring chose it. | Miri | v0.1 |
| S-06 | Meridian coverage follows construction era gradient: new construction (gate cluster) = good coverage, mixed era (Terminal, Bar area) = degraded, Era 1 (maintenance spine) = none. | Miri | template |
| S-07 | 800 permanent population = compact district. Walking distances are short. Everything is known. | Miri | v0.1 |
| S-08 | Back room alley exit leads to the **district edge** (maintenance alley). It is not an interior district route — it accesses the hull boundary, enabling exit without crossing public district space. | Paula, bar layout | v0.1 |
| S-09 | Voss does not appear at the bar. The spatial separation of Triangles 1/2 (Terminal-based) from Triangles 3/4 (Bar-adjacent) is architecturally enforced by Voss's absence from the bar. | Paula | v0.1 |
| S-10 | Maintenance corridor carries **zero regular traffic**. It must be architecturally believable that no worker has reason to enter — it connects restricted storage to the bar's bathroom corridor. That route has no legitimate use. | Paula | v0.1 |
---
### 3. Gameplay Constraints
*What the four gameplay loops require from the spatial layout.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| G-01 | District must support **four gameplay loops** simultaneously: investigation, social observation, smuggling, daily life. Each loop requires distinct spatial affordances that do not conflict. | Gestalt | template |
| G-02 | **Minimum 46 social sites** in the district. Confirmed: Terminal (1), Bar (1), Gate cluster (to design, 1). Remaining 13 sites are unspecified. | Gestalt | template |
| G-03 | **Minimum 2 NPC route convergence points** outside Terminal and Bar — locations where NPCs from different social sites share a path, enabling observation of cross-site relationships. | Gestalt | template |
| G-04 | Gate cluster requires its own **social triangle** (≥3 NPCs with conflicting interests). This is not just a transit hub — it is a social site with investigation affordances. | Gestalt | template |
| G-05 | **District entry = fork**. Player's first spatial decision is directional: left or right, Terminal or Bar side. Both directions are legitimate from moment one. No forced tutorial path. | Ozzie | template |
| G-06 | Transition corridor crossing: **2025 seconds** at Walk stance. Long enough to be a temporal beat; short enough not to become friction. At Walk (1 tile / 2 ticks, 10 tps) = 5 tiles per second = 100125 tiles at 2025s. The corridor as designed (~40m = 40 visual tiles) achieves ~8 seconds — **this is a gap that needs resolving in Round 2** (see Open Tensions T-08). | Ozzie, smuggling layout | v0.1 |
| G-07 | Maintenance corridor is a **gradual unlock**, not a sudden discovery. Player should be able to infer its existence (sound, NPC behavior anomaly, spatial hint) before accessing it physically. | Ozzie | template |
| G-08 | Every ring-associated location must **read as mundane on first pass**. "Nothing looks wrong" must be achievable without prior knowledge. The ring's spatial design principle (from smuggling layout): same physical space, different player understanding. | Ozzie, smuggling layout | template |
| G-09 | Generator district skeleton = **social site positions + access tier topology + traffic routing + observation position set + chokepoint designation**. This is the minimum specification for a generated district to be functional for all four gameplay loops. | Gestalt | template |
| G-10 | Zone-chunk alignment: audio zone transitions (D-073) and access tier transitions should coincide with chunk boundaries where possible, enabling the generator to reason about zones at chunk granularity. | Tyre | template |
---
### 4. Narrative Constraints
*What the five triangle arcs require from the spatial layout.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| N-01 | Triangle 4 (DrinSystemRing) **spans both buildings**. Sera Venn needs a Commission inspection presence at the Terminal — a legitimate reason to be there that doesn't read as suspicious. The spatial layout must provide a Commission inspection point in the Terminal district. | Paula | v0.1 |
| N-02 | No private path between manifest processing and supervisor's office (see H-11). Reiterated here: the spatial cost of Maret choosing to act is the *visibility* of crossing the main corridor. | Paula | v0.1 |
| N-03 | No second entrance to restricted storage (see H-07). The ring's chokepoint is architectural — not a character choice. | Paula | v0.1 |
| N-04 | Bathroom corridor interior zero LOS from bar floor (see H-10). Private ring exchanges in the corridor must be invisible to observers on the bar floor. | Paula | v0.1 |
| N-05 | Exactly two routes between Terminal and Bar (see H-09). The public corridor is the ring's exposure; the maintenance corridor is their bypass. A third route dissolves this asymmetry. | Paula | v0.1 |
| N-06 | Back room alley exit to district edge (see S-08). Enables ring operational flow from bar side without re-crossing bar floor. | Paula | v0.1 |
| N-07 | Voss stays in Terminal spatial zone (see S-09). Triangle 1 and Triangle 2 are Terminal dramas; Triangle 3 is a Bar drama. Voss's absence from the bar is what keeps them separate. | Paula | v0.1 |
| N-08 | Maintenance corridor must be genuinely zero-traffic (see S-10). If workers ever had legitimate reason to use it, the ring's use would not be anomalous. | Paula | v0.1 |
| N-09 | **Path B discovery** (exploration-heavy investigation path) requires the maintenance hatch to be **discoverable from the break room area**. The terminal layout shows break room (southwest) and restricted storage (south-center) as adjacent structures, but the hatch (M-HATCH-T) opens inside restricted storage, not the break room. This is Paula's key topological issue — see Open Tensions T-01. | Paula | v0.1 |
---
### 5. Player Experience Constraints
*What feel and navigation require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| P-01 | **Fork at district entry.** First decision is spatial. Terminal to the left, Bar to the right (or some equivalent directionality). The fork must be immediate and legible — no single entry corridor that forces the player through one building first. | Ozzie | template |
| P-02 | Transition corridor is a **tonal between-space** — not dead space. Moving through it is a beat of reflection: leaving one social temperature (Terminal = institutional cool), entering another (Bar = warm amber). 2025s at Walk stance is the target duration. | Ozzie | template |
| P-03 | Main corridor at the Terminal must **feel dangerous**. Not literally — no combat threat — but the player should feel observed and out of place if they linger. NPC density, the supervisor's window, the chokepoint geometry all combine to produce this. | Ozzie | v0.1 |
| P-04 | Corner booth in the Bar is the **primary investigation observation post**. It must maintain LOS to: entrance, news ticker cluster, bar counter, card table, and back room door. Confirmed by bar layout. Any district modification that disrupts this LOS set breaks the investigation hub. | Ozzie, bar layout | v0.1 |
| P-05 | Maintenance corridor discovery should follow an **inference arc**: player hears something, notices NPC timing anomaly, finds the hatch, enters. Discovery should feel like a reveal, not a stumble. | Ozzie | template |
| P-06 | The "nothing looks wrong" moment — when the player first realizes the mundane spaces are the criminal infrastructure — should emerge from **accumulated observation**, not a single clue. Spatial design must support layered discovery: visit 1 = normal, visit 2 = curious, visit 3 = understood. | Ozzie | template |
---
### 6. Visual / Spatial Constraints
*What the tilemap, art direction, and zone palette require.*
| # | Constraint | Source | Tag |
|---|------------|--------|-----|
| V-01 | Terminal facade (44m wide) requires a **forecourt** — breathing space between the building face and the transition corridor or district spine. The facade cannot abut a corridor directly. | Araminta | v0.1 |
| V-02 | Bar east extension (bathroom corridor, 6m) **faces north**. This fixes the bar's relative orientation: the bathroom corridor opens northward toward the main transit area. The alley exit (south door, row 20 in bar layout) faces the district edge. | Araminta | v0.1 |
| V-03 | Transition corridor requires **widening zones** at both ends — spatial decompression before entering the Terminal forecourt and before entering the Bar entry. Narrow corridor expanding to wide forecourt reads as "arrival." | Araminta | template |
| V-04 | Gate cluster zone palette: **coolest and newest** in district. Era 3 construction, Commission-grade maintenance. Visually distinct from Terminal (cool grey-navy) and Bar (warm amber). Exact palette TBD in Round 2. | Araminta | v0.1 |
| V-05 | **Minimum corridor widths** by type (exact values to be specified in Round 2): maintenance corridor = 2m (confirmed, smuggling layout), transition corridor = 6m (confirmed, smuggling layout), internal building corridors and secondary public corridors = TBD. | Araminta | template |
| V-06 | **LOS anchors every ~4 visual tiles** in open spaces. Large open areas (forecourt, cargo floor, bar main floor) need furniture, pillars, kiosks, or fixtures at regular intervals. These serve dual purpose: visual rhythm and gameplay cover/observation points. | Araminta | template |
| V-07 | No additional **physical z-levels** unless gameplay-justified. Maintenance corridor on z=0, bar/terminal on z=1. Multi-floor structures require a gameplay reason (investigation access to a floor, combat routing). Cosmetic vertical variation is not sufficient justification. | Araminta, Tyre | both |
| V-08 | Zone temperature tints (D-059) must be **distinct and non-overlapping at transition boundaries**. Crossfade handled by D-073 (1.52s audio tween at hard tile boundary). Visual fog tint transition should use the same boundary — player should not be in two zone temperatures simultaneously. | D-059, D-073 | template |
| V-09 | Generator parameterization: minimum viable generator parameters for spatial layout include **facade width ratio** (building width : forecourt depth), **corridor width minimums by type**, **LOS anchor interval** (tiles between anchor objects in open spaces). | Araminta | template |
---
### 7. Open Tensions
*Where constraints conflict or are underspecified — these are Round 2 discussion topics.*
---
#### T-01 — PRIORITY: Break Room Adjacency / Path B Discovery
**What's at stake:** The detective's exploration-heavy investigation path (Path B from smuggling layout) begins with a floor worker in the break room mentioning Kael's odd hours, then the detective physically discovering the maintenance hatch. But the hatch (M-HATCH-T) opens inside *restricted storage*, not the break room. The break room (terminal layout rows 2226, west) is southwest; restricted storage (rows 2730, south-center) is south-center. They are adjacent but not connected.
**Paula's three options:**
- **Option A — Shared wall with sound propagation.** Break room and restricted storage share an interior wall. A worker in the break room can hear sounds through the wall that hint at activity in the maintenance corridor (footsteps on grating). Player hears anomaly → investigates restricted storage → discovers hatch.
- **Option B — Disused side passage.** A disused (non-traversable) side passage between break room and restricted storage area gives physical proximity to the hatch area without creating a second route to restricted storage.
- **Option C — Path B starts on cargo floor.** A worker hears sounds near restricted storage while on the cargo floor (not the break room). Path B's starting location shifts from break room to cargo floor.
**Why it needs resolving before Round 3:** The option chosen affects the terminal layout (wall topology between zones) and the district layout (is the break room at the southwest corner, or does it need repositioning?). **This is the highest-priority Round 2 discussion item.**
**Generator implication [template]:** Which option generalizes? Option A (shared wall + sound propagation) is generalisable — it establishes "investigation paths can start with audio anomaly from adjacent zone." Options B and C are more specific to this layout.
---
#### T-02 — Gate Cluster Scope and Triangle
**What's at stake:** Gestalt requires the gate cluster to be a full social site with its own triangle (≥3 NPCs). Miri requires freight/commuter flow separation at the gate. Together these imply the gate cluster is substantial — a fourth major location, not just a corridor junction. But no scope has been defined: how large? How many zones? What triangle roles?
**The tension:** A large gate cluster is more content work than a small one. The district tile budget has room, but the content authoring budget (triangles, NPC lines) may not. Round 2 needs a concrete proposal for gate cluster size and triangle composition, or a decision to scope it down.
**Generator implication [template]:** Gate cluster = entry node in district skeleton. Every freight district has one. The question is whether entry nodes always carry a social triangle or whether that's optional. If Q-036's district skeleton requires a triangle at every social site (Gestalt's minimum is 46 sites with triangles), entry nodes need triangle support.
---
#### T-03 — Commuter Transit Entry Point Placement
**What's at stake:** Miri requires a separate commuter transit connection (not the span gate). This creates a second district entry point. Where it sits relative to the Terminal and Bar has large implications for NPC traffic patterns and ring exposure.
**Two principal options:**
- **Option A — Gate-cluster-integrated.** Commuter transit is adjacent to the span gate — same spatial cluster, separate lanes. All inbound traffic (freight and passenger) arrives in the same zone, then fans out. This simplifies district topology but creates a single convergence point that's easier to monitor.
- **Option B — Separate entry point, Bar-side.** Commuter transit hub is on the bar side of the district (near the bar, away from the terminal). Workers arrive near their social space, not their workplace. This produces two active entry zones and richer NPC traffic routing — but complicates ring exposure analysis.
**Generator implication [template]:** Two-vector entry is Miri's universal freight-district template. The generator needs to know whether the two vectors are co-located (same cluster) or distributed (separate district zones). This is a structural parameter.
---
#### T-04 — Remaining Social Sites (13 Unidentified)
**What's at stake:** Gestalt requires 46 social sites. Confirmed: Terminal, Bar, gate cluster = 3. One to three more sites are unspecified.
**Candidates from existing documentation:**
- Commuter transit hub (if Option B from T-03 — separate location with its own social dynamics)
- Sector 3 (Miri — named area with ventilation issue; has a location but no defined social site yet)
- Maintenance junction node (a secondary gathering point in the maintenance spine? Non-obvious)
- A commissary, clinic, or administrative sub-office (generic service space with resident NPCs)
**What Round 2 needs:** Names and rough positions for the remaining social sites, or a decision to scope to 3 (Terminal + Bar + gate cluster) and justify why that satisfies Gestalt's minimum. Note: 3 may be sufficient if the gate cluster is large enough to function as 1.5 sites.
**Generator implication [template]:** Social site count is a generator parameter (minimum: 4). The template needs named social site types and their relationship requirements (which types must be adjacent, which must be separated).
---
#### T-05 — Transition Corridor Crossing Time
**What's at stake:** Ozzie requires 2025 seconds at Walk stance for the corridor crossing. At Walk (1 tile / 2 ticks, 10 tps = 5 tiles/sec), 2025 seconds = 100125 visual tiles. The transition corridor in the smuggling layout is ~40m = 40 visual tiles, achieving only ~8 seconds.
**Options:**
- **Option A — Extend the corridor.** Make the physical transition corridor 100125 tiles. This is very long (100125m) and may not fit the station profile ("compact district, 800 population").
- **Option B — Accept 8 seconds.** Adjust Ozzie's target to match the physical reality. 8 seconds at Walk is not nothing — it's still a beat. Ozzie's 2025s may be aspirational rather than hard.
- **Option C — Add intermediate spaces.** The transition route includes the forecourt (Terminal side) and entry zone (Bar side). If total route = corridor + forecourt + bar entry area, total walking distance may reach 6080 tiles (~1216 seconds). Closer to the target without an implausibly long corridor.
**Generator implication [template]:** Inter-site transit time is a gameplay parameter. The template should specify minimum/maximum transit time between major social sites, not corridor length directly.
---
#### T-06 — Maintenance Spine Route (Era 1 vs. Efficient Path)
**What's at stake:** Miri says the maintenance spine predates the buildings (Era 1 construction). If the spine's route is fixed and buildings were placed around it, the maintenance corridor between Terminal and Bar may not run in the most direct path. But the smuggling layout shows a relatively direct corridor connection. Is the route direct (efficient) or wandering (Era 1 authentic)?
**The tension:** A wandering Era 1 corridor is setting-authentic but adds tile complexity and may not fit cleanly in the district layout. A direct corridor is simpler to lay out but slightly undermines Miri's historical rationale.
**Generator implication [template]:** Maintenance spine routing is a generator parameter. The template should specify whether maintenance corridors follow the shortest path or use a historically-layered routing algorithm.
---
#### T-07 — Corridor Width Minimums (Unspecified)
**What's at stake:** Araminta flagged minimum corridor widths by type but did not provide values. Confirmed: maintenance corridor = 2m (2 visual tiles), transition corridor = 6m (6 visual tiles). Unspecified: internal building corridors, secondary public corridors, service alcoves.
**Round 2 needs:** Explicit minimum widths for each corridor type. These become V-05's specified values and feed into the generator's spatial layout rules.
---
#### T-08 — Gate Cluster Zone Temperature Tint
**What's at stake:** D-059 assigns zone temperature tints to Terminal (cool dark), Bar (warm dark), corridors (neutral dark). The gate cluster is a fourth zone type requiring a tint. Araminta says it's the "coolest and newest" — suggesting a colder tint than the Terminal. But D-059 already uses "cool dark" for the Terminal. The gate cluster needs a distinct value.
**Options:** Very cool (near-white institutional), clinical blue-white, or a Commission-grey that reads as "newer" than Terminal's grey-navy.
**Generator implication [template]:** Zone temperature tint is a per-zone-type parameter. The template needs a tint for each of: logistics-hub, social-venue, transit-corridor, entry-gate. Currently only the first three are decided.
---
## §2 — ROUND 2: Cross-Examination and Lead Feedback
*Compiled by Qatux. Sources: 6 agent Round 2 contributions + lead feedback that resolved T-01 and T-05.*
---
### Lead Feedback (resolved before agents responded)
**Chunk size direction:** Lead prefers larger chunks with a sub-chunk quarter system. Chunks divide into 4 quarters that can merge into one edifice or remain separate. Large civic structures (train stations, government buildings) span multiple chunks. Generator must be top-down: geography → infrastructure → amenities → population → zoning → chunk generation → individual fill. Separate generator architecture workshop required — this is architectural precedent, not v0.1 configuration.
**Z-level PoC:** Lead mandates one building in the district with a staircase as z-level proof-of-concept. Gate cluster observation gallery selected by consensus — the only unconfirmed location, setting-authentic, investigation-valuable, clean implementation test case. Overrides V-07's "no z-levels without justification" — the PoC IS the justification.
**T-01 resolved — Option C:** "Hearing through walls is a flimsy core proposition. We don't build out of cardboard." Sound-through-walls is an exception, not a pattern. Detection toolkit is cameras, drones, bugs, maintenance shafts/vents/tunnels. Path B starts on the cargo floor, not the break room. Terminal layout #311 unchanged.
**T-05 resolved — Careful stance:** Confirmed. Full route at Careful (3.33 tiles/sec per D-053) ≈ 80 tiles = ~24 seconds. Constraint is stance-dependent, not corridor-length-dependent. No layout change needed.
**Gate/transport lore clarification (S-02 revision):** System gates serve only freight externally. "Commuter transit" = internal station transit (train/tram) from Residential Core. One external entry (span gate). One internal transit stop within the district. T-03 reframed as T-03b: where does the internal transit stop sit?
**Transport lore questions:** Captured as Q-040Q-044. All assigned to Miri.
---
### Agent Round 2 Positions
**Ozzie (Player Experience)**
- Confirmed T-05 can be satisfied by Careful stance measurement — accepts this resolution
- "Strong yes" on G-11 (gate cluster observation gallery as player investigation vantage point)
- Flagged V-06 exception: Terminal main corridor should be bare of LOS anchors — the exposure is the gameplay mechanic, not a design oversight. Open spaces that are *meant* to feel dangerous are exempt from the anchor rule
- Requested G-08 receive an official name in the D-record (the "nothing looks wrong" principle — mundane face on criminal infrastructure)
**Gestalt (Systems Design)**
- Gate cluster triangle composition: customs officer + freight forwarder + waiting commuter (3 NPCs, distinct interests, credible spatial conflict)
- 4th social site = **transit hub** (bar-side internal transit stop has its own NPC population, social dynamics, and convergence function). This resolves T-04 — site count: Terminal + Bar + Gate cluster + Transit hub = 4, satisfying G-02 minimum
- G-11 confirmed: observation gallery at z=2 is a valid **investigation vantage point** — qualifies as a gameplay-justified z-level (H-13 / V-07 condition satisfied by lead's PoC directive)
**Miri (Worldbuilding)**
- [PENDING Round 3 — horizon station revision, T-04 position, Commission overlap resolution]
- Internal transit stop: bar-side position accepted (workers commute to bar district, then walk to Terminal for shift)
- Transport lore model submitted — see Q-040Q-044 for captured questions
**Tyre (Technical)**
- [PENDING Round 3 — chunk size technical confirmation at 64×64 visual, z-level validation, cross-z shadowcasting spec]
**Araminta (Visual/Spatial)**
- **Corridor width minimums (V-05 now specified):**
| Corridor type | Width (visual tiles) | Width (meters) |
|--------------|---------------------|----------------|
| Maintenance corridor | 2 | 2m |
| Internal building corridors | 2 | 2m |
| Secondary public corridors | 4 | 4m |
| Transition corridor | 6 | 6m |
| Gate customs lanes | 2 | 2m (per lane) |
| Gate concourse | 8 | 8m |
| Service alcoves | 1 | 1m |
- **Gate cluster zone temperature tint:** `#0a1520` — deep institutional cold, distinct from Terminal's cool-grey-navy. Coldest zone in the district
- **Gate observation gallery spec:** 4 tiles wide × lane-length, Commission grey-white palette. Gallery floor is z=2; same zone tint as gate cluster ground floor (elevation ≠ new zone)
- **Outlier on chunk size:** Araminta prefers 64×64 sim (32×32 visual). Rationale: visual tile is the authoring unit; generator should reason at visual-tile scale. Noted as minority position
**Paula (Narrative)**
- **Gate cluster triangle revised:** operations manager + senior freight handler + Commission inspector (3 NPCs). Rationale: Commission inspector is Sera Venn's institutional peer — this creates the Commission overlap (N-01) without requiring Sera to be permanently stationed at Terminal. Inspector visits = legitimate, scheduled, observable
- **T-01 revised:** Path B via restricted storage door, not break room wall. The anomalous sound is footsteps on the maintenance grating, heard through the (imperfect) seal around the restricted storage access door on the cargo floor — not sound through a solid wall. The door is the weak point, not the wall. This preserves Option C without invoking cardboard-wall physics
- **Path C confirmed:** A third investigation approach for the detective — the Commission inspection overlap. The Commission inspector (gate cluster triangle NPC) has access to the same manifest anomalies as Maret but reads them as institutional compliance failures, not criminal ones. Detective PC who befriends the inspector gets a different angle on the evidence: institutional rather than human
- **Sector 3:** Supports Miri's framing — sub-area adjacent to maintenance spine, ventilation complaint is ambient NPC dialogue, not a full social site
- **6 narrative constraints for D-record:** Commission inspector has access to gate cluster AND Terminal (inspection authority crosses building boundaries); inspector's Terminal visits are scheduled (visible, predictable — ring can route around them); Sera is a bar regular who knows the inspector professionally (Triangle 4 cross-link); no NPC in the gate cluster triangle has social connection to Voss (Terminal triangle separation maintained); transit hub NPCs are socially isolated from the Terminal workers (two distinct working cultures); back room alley exit connects to maintenance alley which connects to district edge, NOT to the transit hub service area
---
### Round 2 Tension Status
| Tension | Status after Round 2 | Resolution |
|---------|---------------------|------------|
| T-01 Break room adjacency | **RESOLVED** | Option C: cargo floor + restricted storage door acoustic gap |
| T-02 Gate cluster scope | **RESOLVED** | Two competing triangle compositions (Gestalt vs. Paula) — Round 3 to pick one |
| T-03b Transit stop placement | **RESOLVED** | Bar-side — consensus |
| T-04 4th social site | **RESOLVED** | Transit hub (bar-side) |
| T-05 Corridor crossing time | **RESOLVED** | Careful stance ~24s on ~80-tile route |
| T-06 Maintenance spine routing | **RESOLVED** | Direct path for v0.1; wandering routing deferred to generator workshop |
| T-07 Corridor widths | **RESOLVED** | Araminta's table (see above) |
| T-08 Gate cluster tint | **RESOLVED** | `#0a1520` deep institutional cold |
| NC-01 Transit stop placement | **RESOLVED** | Same as T-03b |
| NC-02 G-10 chunk alignment | **PARTIALLY RESOLVED** | At 64×64 visual chunks, chunk ≈ zone; G-10 revised accordingly |
| NC-03 Gallery tint (z=2) | **RESOLVED** | Same tint as gate cluster ground floor |
| NC-04 Sector 3 / maintenance spine | **RESOLVED** | Adjacent; ventilation = ambient NPC dialogue |
**Remaining for Round 3:** Gate cluster triangle — pick Gestalt's composition or Paula's. Chunk size — confirm 64×64 visual (Tyre analysis pending). Miri's horizon station revision and Commission overlap resolution.
---
## §3 — ROUND 3: Convergence
*Compiled by Qatux. All 6 agents delivered Round 3. All tensions resolved. D-records filed: D-093, D-094, D-095.*
---
### Confirmed Consensus
**T-01 (break room / Path B):** Option C confirmed by lead. Sound anomaly at restricted storage door (cargo floor) → Path B. Paula's refinement accepted: acoustic gap is the door seal, not a wall. No modification to terminal layout #311.
**T-03b (transit stop):** Bar-side. Unanimous. Creates NPC convergence point at bar entry (satisfies G-03).
**T-04 (4th social site):** Sector 3 residential (Drin/Naia anchor). Miri's Round 3 revision supersedes the interim Transit Hub consensus. The transit platform (The Loop stop) is reclassified as an encounter node (bar-side convergence point). Site count = 4 (Terminal, Bar, Gate cluster, Sector 3 residential). G-02 minimum satisfied.
**T-05 (crossing time):** Walk ~1314s on ~6570 tile route; Careful stance ~24s. Ozzie confirms. No layout change.
**T-06 (maintenance spine routing):** Direct path for v0.1. Generator-level concern deferred.
**T-07 (corridor widths):** Araminta's table confirmed. Filed as V-05 specified values.
**T-08 (gate cluster tint):** `#0a1222` deep institutional cold (Araminta final). Gallery at z=2 shares ground-floor tint.
**G-11 (observation gallery as investigation vantage):** Confirmed by Gestalt + Ozzie. The gate cluster observation gallery at z=2 is a designated investigation position — the player can observe arriving cargo from elevation. This is the gameplay justification for the z-level PoC.
**G-08 naming:** Confirmed name: **"Invisible infrastructure principle"** — every ring location serves a mundane purpose; criminal function is only apparent if you know what to look for. This is the spatial design principle stated in the smuggling layout and now formally named.
**V-06 exception (main corridor):** The Terminal main corridor is explicitly exempt from the LOS anchor rule. The bare, unobstructed corridor is the gameplay mechanic — exposure IS the design. Araminta confirmed.
**Gate cluster triangle:** Paula's composition selected — operations manager + senior freight handler + Commission inspector. Rationale: Commission inspector creates the narrative bridge (N-01, Path C, Sera's institutional peer) that Gestalt's commuter-based composition cannot provide.
**Path C (Commission inspection overlap):** Confirmed. The Commission inspector at the gate cluster has institutional access to Terminal manifest data. Detective PC who builds relationship with inspector gains a third, institutional angle on the evidence. Non-confrontational. Sera Venn cannot access gate cluster customs records without a formal Commission request — separate institutional chains.
**Sector 3 residential (S-04):** Sub-area adjacent to maintenance spine. 4th confirmed social site (D-025). Anchor NPCs: Drin and Naia (ongoing ventilation dispute). Industrial Sector jurisdiction. Ambient dialogue provides local texture without plot relevance.
**Commission overlap resolution (N-01):** Gate cluster customs zone is Commission-jurisdictioned space. Commission inspector has scheduled inspection authority crossing into Terminal. This explains Sera's legitimate Terminal presence without requiring her to be stationed there.
**Chunk size (confirmed):** Chunk = 64×64 sim (32×32 visual, 32m) — streaming unit. Block = 128×128 sim (64×64 visual, 64m) — generator planning unit, 4 chunks. District = 4×4 blocks = 512×512 sim (256×256 visual, 256m). Quarter = 32×32 visual within a block (sub-block unit for generator fill). Araminta dissent (preferred 32×32 visual chunk) noted, overruled. Amends D-012; supersedes D-014 estimate. Filed as D-094.
**Z-level scheme (confirmed):** z=0 maintenance corridor (Era 1), z=1 all main district structures (Terminal, Bar, Gate cluster ground, transit platform), z=2 Gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall; player on z=2 sees z=1 below; z=1 cannot see upward unless at staircase. Confirmed by Tyre.
**Transport lore (Miri — confirmed):** Span gates: human-built, single aperture, dual-use windows (freight/passenger). Horizon stations: alien-built, 48 apertures, Oort-cloud distance, "The Ring" per-system. Sequential hop travel only. The Loop: 6-district internal tram, 4min Residential Core → Transit District. Station profile correction: Sova's horizon gates at The Ring, not Admin Hub. Q-040/Q-041/Q-043/Q-044 resolved → D-093/D-095. Q-042 partially resolved. Filed as D-095.
---
### Round 3 — All Items Resolved
All tensions and open items from Rounds 1 and 2 resolved. D-records filed: D-093 (`decisions/content.md`), D-094 (`decisions/architecture.md`), D-095 (`decisions/content.md`). No pending items.
---
## §4 — D-RECORDS FILED
*Filed by Qatux, 2026-02-25. D-093 (Sova Transit District spatial layout), D-094 (District spatial hierarchy), D-095 (Horizon stations and gate infrastructure). See `decisions/content.md` and `decisions/architecture.md`.*
---
### D-093: Sova Transit District — Spatial Layout and District Topology
**Decision file:** `decisions/content.md`
**Date:** 2026-02-25
**Source:** Station District Layout Workshop, Ticket #153 (Sprint 20)
**Raised by:** Full team (Gestalt, Miri, Araminta, Tyre, Paula, Ozzie). Compiled by Qatux.
**Dissent:** Araminta on chunk size (prefers 32×32 visual chunk; overruled by lead and team majority). No other dissent.
---
#### Decision
The Sova Transit District spatial layout is confirmed as follows.
---
#### 1. District Topology
```
N (external — span gate, horizon station connection)
┌─────────────────────────────────────────┐
│ GATE CLUSTER │ ~40×32 visual tiles [T-02 est.]
│ z=1: gate floor, customs lanes (2m ea),│ Tint: #0a1222 (institutional cold)
│ concourse (8m), processing zone │ Access: PUBLIC (concourse)
│ z=2: observation gallery (4 tiles wide)│ SEMI-PRIVATE (customs lanes)
│ Commission grey-white palette │ PRIVATE (inspection booths)
└────────────────┬────────────────────────┘
│ forecourt (~810 tiles deep)
┌────────────────┴────────────────────────┐
│ TERMINAL (Sova Logistics Hub) │ 44×28 visual tiles (confirmed #311)
│ z=1. Tint: cool grey-navy │ Access: PUBLIC (entry lobby)
│ Entry lobby → scanner bays → │ SEMI-PUBLIC (main corridor)
│ main corridor → cargo floor / │ SEMI-PRIVATE (cargo floor,
│ manifest processing / break room → │ manifest proc., break room)
│ restricted storage (coded, 1 door) │ PRIVATE (supervisor office,
│ Supervisor office: [=] window south │ restricted storage)
└────────────────┬────────────────────────┘
│ ← maintenance corridor z=0 branches east here
│ (restricted storage → M-HATCH-T → 40m → M-HATCH-B)
TRANSITION CORRIDOR
~40m × 6m (6 visual tiles wide) Tint: neutral dark
Surveillance camera at T=24m Access: SEMI-PUBLIC
Widening zones at both ends
(forecount N-side, bar entry S-side)
│ ← maintenance corridor z=0 terminates at M-HATCH-B
│ (opens into bar bathroom corridor)
┌────────────────┴────────────────────────┐
│ TRANSIT PLATFORM (encounter node) │ [dimensions TBD — ~12×8 visual est.]
│ z=1. The Loop tram stop (bar-side) │ Tint: neutral-warm (transitional)
│ (tram from Residential Core) │ Access: PUBLIC
│ NPC population: commuters, workers. │
│ Convergence point #2 (G-03). │
└────────────────┬────────────────────────┘
│ (bar entry zone, ~5 tiles)
┌────────────────┴────────────────────────┐
│ BAR — THE LAST SHIFT │ 28×22 + 6m east extension visual (confirmed #312)
│ z=1. Tint: warm dark amber │ Access: PUBLIC (main floor, card table)
│ East extension (bathroom corridor) │ SEMI-PRIVATE (serving south,
│ faces north toward transit hub. │ bathroom corridor)
│ Alley door (south) → maintenance │ PRIVATE (back room)
│ alley → district edge. │
└─────────────────────────────────────────┘
S (maintenance alley — district edge)
─────────────────────────────────────────────────────────────────────
MAINTENANCE CORRIDOR (z=0, runs parallel to transition corridor)
[RESTRICTED STORAGE north wall / cargo floor door]
→ M-HATCH-T (locked hatch, restricted storage interior)
→ EAST SERVICE SPINE (~15m, narrow, dim)
→ JUNCTION-1 (DD-2 dead-drop location)
→ [side branch west] SECTOR 3 RESIDENTIAL (~15×12 visual)
Drin/Naia (ventilation dispute anchor)
4th social site (D-025). Access: PUBLIC
→ DISTRICT SPINE (~25m, grating floor)
→ DD-3 "The Mark" (go/no-go signal, mid-corridor)
→ M-HATCH-B (locked hatch, bar bathroom corridor)
Era 1 construction. No Meridian coverage. Grating floors.
Zero LOS from all other spaces. Zero regular NPC traffic.
Access: PRIVATE (ring members only in practice).
```
---
#### 2. Zone Dimensions
| Zone | Visual tiles | Sim tiles | Notes |
|------|-------------|-----------|-------|
| Gate cluster (z=1) | 40×32 | 80×64 | Araminta confirmed — 7 zones (see §4.2 zone spec) |
| Gate observation gallery (z=2) | 32×10 | 64×20 | Commission grey-white; Araminta confirmed |
| Terminal | 44×28 | 88×56 | Confirmed (#311) |
| Forecourt (Terminal N-face) | ~44×10 | ~88×20 | Estimate |
| Transition corridor | ~40×6 | ~80×12 | Confirmed (#313) |
| Transit platform | ~12×8 | ~24×16 | Encounter node — The Loop stop; bar-side |
| Sector 3 residential | ~15×12 | ~30×24 | 4th social site (D-025); maintenance spine adjacent |
| Bar entry zone | ~28×5 | ~56×10 | Estimate |
| Bar (Last Shift) | 28×22 (+6m E ext.) | 56×44 (+12 E ext.) | Confirmed (#312) |
| Maintenance corridor | ~40×2 | ~80×4 | Confirmed (#313) |
| **Total district bounding box** | **~256×256 visual** | **~512×512 sim** | 4×4 blocks per D-094; D-014 estimate superseded |
---
#### 3. Access Topology
Sequential. No tier skipping (H-06).
```
PUBLIC → SEMI-PUBLIC → SEMI-PRIVATE → PRIVATE
────── ─────────── ──────────── ───────
Gate concourse Transition corridor Terminal cargo floor Supervisor office
Gate floor Terminal main corridor Terminal manifest proc. Restricted storage
Terminal lobby Gate customs entry Terminal break room Maintenance corridor
Transit hub Bar serving south Bar back room
Bar main floor Bar bathroom corridor
Bar card table
Bar counter (cust. side)
```
Movement across tiers requires: worker role (trivial), authority (badge → semi-private), access code (private), ring membership (maintenance corridor/restricted storage).
---
#### 4. Spatial Hierarchy (Confirmed Naming)
- **Chunk** = 64×64 sim (32×32 visual, 32m) — streaming and serialization unit
- **Block** = 128×128 sim (64×64 visual, 64m) — generator planning unit; composed of 2×2 chunks (4 chunks per block)
- **District** = 4×4 blocks = 512×512 sim (256×256 visual, 256m); 16 blocks, 64 chunks per z-level
- **Chunk merge rules:** Adjacent chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes, shacks), or form L-shaped buildings across chunk boundaries
- **Large civic structures:** Span multiple blocks (gate cluster, horizon station installations, stadiums, parks)
- **District skeleton:** Each major social site occupies approximately one chunk (32×32 visual) within its block
**Generator architecture note:** The full top-down generator model (geography → infrastructure → amenities → population → zoning → block generation → chunk fill) requires a dedicated workshop brief. This chunk/block/district specification is the spatial primitive for that future system. Q-036 tracks district skeleton design.
---
#### 5. Z-Level Scheme
| Level | Contents | Notes |
|-------|----------|-------|
| z=0 | Maintenance corridor (Era 1) | Full district. Dim cold-white lighting. Grating floors. No Meridian. |
| z=1 | All main district structures | Terminal, Bar, Gate cluster ground, transit platform, Transition corridor |
| z=2 | Gate cluster observation gallery only | 4 tiles wide × lane-length. Commission grey-white palette. Access via staircase in gate cluster. |
**Cross-z LOS (confirmed, Tyre):** Vertical LOS propagates only through designated transparent floor/window tiles. Opaque floor tile = full LOS block. Gallery rail = transparent floor tile, designer-placed. Player on z=2 has LOS downward through transparent rail to gate floor (z=1); LOS does not propagate upward from z=1 except at staircase opening. Performance cost: ~100150µs per additional FOV pass — trivial. Gallery observation capability is architecturally controlled by designer tile placement, not a special case.
**Inter-z sound (confirmed, Tyre):** Sound propagates across z-levels only through open hatches and designated vent tiles. The maintenance corridor (z=0) is inaudible from z=1 except at hatch locations (M-HATCH-T inside restricted storage, M-HATCH-B in bar bathroom corridor). This makes the acoustic gap at the cargo floor door (Path B) mechanically coherent — it is a z=1 surface feature, not a z=0 leak.
**Memory (confirmed, Tyre):** Three z-levels for this district = ~1.35MB. Trivial.
**Z-level PoC purpose:** The gate cluster observation gallery proves the z-level rendering stack (D-049) and cross-z shadowcasting (D-035) for all future development. This is the only z=2 space in v0.1.
---
#### 6. Key Sightline Relationships
*Across the full district:*
| From | To | LOS | Notes |
|------|----|-----|-------|
| Gate observation gallery (z=2) | Gate floor below (z=1) | ✓ downward | Player sees cargo off-loading from above |
| Transition corridor (any position) | Terminal exterior / Bar exterior | ✗ | Buildings are opaque from outside |
| Transit hub | Bar entry zone | ✓ | Convergence point — workers arriving see bar entrance |
| Maintenance corridor | Anywhere | ✗ | Zero LOS in or out. Sound only. |
| Maintenance hatch (M-HATCH-T) | Cargo floor | ✗ | Hatch opens inside restricted storage — only visible to someone already in restricted storage |
*Within Terminal (from #311 — unchanged):*
- Main corridor → all four south-facing doors: ✓
- Supervisor window [=] → cargo floor + restricted storage door: ✓
- Break room → anything: ✗ (isolated)
- Manifest processing → main corridor (door open): ✓
*Within Bar (from #312 — unchanged):*
- Corner booth (NW deepest) → entrance, bar counter, card table, back room door: ✓
- Bathroom corridor interior → main bar: ✗ (door only)
---
#### 7. NPC Routes and Convergence Points
Two confirmed convergence points outside Terminal and Bar (satisfies G-03):
1. **Terminal main corridor** — all Terminal workers cross here. Every person moving between scanner bays (north) and cargo floor / manifest processing / supervisor office (south) passes through. Semi-public; lingering is suspicious.
2. **Transit platform / bar entry zone** — workers arriving from Residential Core via The Loop tram. Some walk north to Terminal, some enter bar directly. NPCs from different social sites share this arrival space. Encounter node, not social site.
Additional convergence: **Transition corridor** — ring members and workers both use this corridor. The surveillance camera at T=24m records all transits. Pattern analysis reveals ring operational schedule.
---
#### 8. Investigation Paths (confirmed)
**Path A — Pattern Recognition (insert-heavy):**
Corridor camera logs → Kael's deep-night transit pattern → manifest database access (manifest processing) → restricted storage access timing → ring identified.
**Path B — Physical Traversal (exploration-heavy):**
Cargo floor: anomalous sound at restricted storage door (acoustic gap around coded door seal) → floor worker conversation → physical discovery of maintenance hatch → corridor traversal → dead-drops discovered. *Note: sound propagation through door seal, not wall. Door is the weak point.*
**Path C — Commission Inspection Overlap (institutional):**
Detective builds relationship with Commission inspector (gate cluster triangle NPC) → inspector shares institutional read of manifest anomalies (compliance framing, not criminal) → detective gains third angle on same evidence. Non-confrontational. Complements Paths A and B.
---
#### 9. Transport Lore
*Captured from workshop discussion:*
**Span gates (Q-040 — resolved → D-093):** Human-built. Single aperture. Near-instantaneous transit. Scheduled dual-use windows: freight (bulk of operating hours) and passenger (scheduled slots). Physical layout reflected in gate cluster zone spec (§4.2).
**Horizon stations (Q-041 — resolved → D-095):** Alien-built (no identified builder species). Self-maintaining. 48 apertures per station. Located at Oort-cloud distance. Per-system canonical name: "The Ring." Sequential hop travel only (A→B→C through intermediate systems; no direct long-range transit). Per-system variation across 4 access tiers. Station Sova's horizon gates are at The Ring — Admin Hub contains booking offices only.
**Intra-system transport (Q-042 — partially resolved):** Span gates at star/planetary level plus horizon stations at Oort distance. Details of intra-system hab-to-hab transit remain open.
**Station internal transit (Q-043 — resolved → D-095):** "The Loop" — internal station tram, 6 districts, 4-minute Residential Core → Transit District run. Workers arrive at transit platform (bar-side) and disperse to Terminal or bar.
**Gate-train integration (Q-044 — resolved → D-093/D-095):** Arriving passengers exit span gate → aperture chamber → freight/passenger customs lanes → gate concourse (public) → transition corridor → transit platform (The Loop). No direct gate-to-tram connection; transition corridor is the linking space.
---
#### 10. Gate Cluster Social Triangle
**Composition:** Operations manager + senior freight handler + Commission inspector
**Spatial staging:**
- Operations manager: works the gate floor (supervises cargo processing, knows every shipment)
- Senior freight handler: works customs lanes (clears cargo for transit; knows what should and shouldn't be there)
- Commission inspector: scheduled inspection visits (legitimate authority, reads anomalies institutionally)
**Triangle tension:** Operations manager and freight handler have an established working relationship — and a shared interest in not attracting Commission attention. Commission inspector is an outside force disrupting their equilibrium. Investigation opportunity: the inspector sees things the manager and handler want invisible.
**Commission overlap (N-01, Path C):** Inspector has institutional access to Terminal manifest data. Sera Venn's professional peer. Detective who builds relationship with inspector gains Path C.
---
#### 11. Invisible Infrastructure Principle (G-08, now named)
Every ring location serves a mundane purpose. Criminal function is only apparent if you know what to look for. The player (smuggler PC) knows. The detective PC can learn. An NPC observing in isolation sees nothing unusual. Same physical space — different player understanding.
This is the core spatial design principle for the district. All investigation paths are designed around it. The principle is generalisable: it applies to any district in the Reach that hosts a Tier 1 module.
---
#### 12. Narrative Constraints (Paula — all confirmed)
1. Commission inspector has inspection authority crossing gate cluster AND Terminal — schedules are publicly known
2. Inspector's Terminal visits are scheduled and predictable — ring can route around them; gives ring temporal structure
3. Sera Venn is a bar regular who knows the inspector professionally — Triangle 4 cross-link
4. No NPC in the gate cluster triangle has social connection to Voss — Terminal and gate cluster triangle separation maintained
5. Transit hub NPCs are socially isolated from Terminal workers — two distinct working cultures; they share the transition corridor but not social space
6. Back room alley exit connects to maintenance alley → district edge; does NOT connect to transit hub service area
---
#### Rationale
The district layout emerged from three rounds of cross-domain synthesis:
- Round 1 established 52 constraints across 6 domains and 8 open tensions
- Round 2 resolved 10 of 11 tensions; lead feedback resolved T-01 (cargo floor) and T-05 (Careful stance)
- Round 3 achieved consensus on all remaining items
The layout satisfies: D-025 (social sites as atomic template units), D-036 (Sova Transit District setting), D-054 (tile-based movement), D-059 (fog zone temperature tints), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice success criteria).
The chunk/quarter system and top-down generator model establish architectural precedent for Q-036 (district skeleton as generator output).
---
#### Cross-References
- Terminal layout: `docs/design/spatial-layout-terminal-v01.md` (#311)
- Bar layout: `docs/design/spatial-layout-bar-v01.md` (#312)
- Smuggling corridors: `docs/design/spatial-layout-smuggling-corridors-v01.md` (#313)
- Station profile: `docs/design/sova-station-profile.md` (#320)
- Gate cluster layout: `docs/design/spatial-layout-gate-v01.md` (to be authored — blocks #157)
- Transit hub layout: TBD (new ticket required)
- Transport lore open questions: Q-040Q-044
- Generator architecture workshop: pending brief
---
*D-records filed: D-093 (decisions/content.md), D-094 (decisions/architecture.md), D-095 (decisions/content.md). Gate cluster full layout to be authored as #157. District bounding box (256×256 visual) supersedes D-014 estimate per D-094.*
+115
View File
@@ -0,0 +1,115 @@
# Sprint 20: Shape — Client Tasks
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
**Branch:** `client`
**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 19
None — Sprint 19 complete. #554 (save/load client UI) is finishing in Sprint 19.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #557 | Refactor: game_state.gd derived state in apply_snapshot() | — |
| #558 | Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling | — |
| #559 | Refactor: main.gd god coordinator — extract SnapshotEventRouter | — |
| #560 | Refactor: unify duplicate YAML parsers | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (Godot is a pure renderer: no game logic in GDScript; GameState reflects server-authoritative data, not derived behavior), D-085 (per-game save directory structure: `user://saves/<timestamp>-<seed>/`, F5=quicksave, F6=quickload, loading screen lists dirs by last-modified), D-088 (3-state pause system: client sends pause requests, server is authoritative)
- `decisions/scope.md` — D-027 (vertical slice success criteria: game session must be resumable for 30-min playthroughs)
## Notes
### #557 — Refactor: game_state.gd derived state in apply_snapshot()
Code review finding: `apply_snapshot()` in `client/scripts/autoloads/game_state.gd` computes two derived values inline:
- `stationary_ticks` (increments when player position hasn't changed) — line 99 / ~line 131-133
- `current_zone_id` (derived from tile iteration) — line 105 / ~line 286
Per D-020, the Godot client is a pure renderer. Behavior-driving computations (stationary tick counting, zone identification) belong in the server, not in `apply_snapshot()`. The server already sends `zone_id` per tile — the client should read it directly rather than re-deriving it.
What this ticket must deliver:
- Move `stationary_ticks` accumulation out of `apply_snapshot()`. The server sends `stationary_ticks` (or equivalent) in the snapshot — if not yet present, add the field to `ObserverSnapshot` in `client/scripts/protocol/protocol.gd` and mark with a TODO for the server team to populate it. Client reads the server value directly.
- Move `current_zone_id` resolution to a simple property read from the snapshot (`player_tile.zone_id`), removing the tile iteration loop from `apply_snapshot()`.
- After: `apply_snapshot()` contains only direct field assignments from the snapshot dictionary — no conditional logic, no accumulation.
- Add a comment citing D-020 on each removed computation to document the rationale.
- Unit tests: `apply_snapshot()` with a snapshot missing the new fields should degrade gracefully (default values, no crash).
Integration points: `client/scripts/autoloads/game_state.gd` only. Protocol fields may need a minor extension in `client/scripts/protocol/protocol.gd` — coordinate with server team if new snapshot fields are required.
Gotcha: `stationary_ticks` drives `ListeningFocus` (D-071) — confirm the server already tracks and sends this value before removing client-side accumulation. If the server does not yet send it, add a feature-flagged fallback that keeps the old behavior with a deprecation comment.
### #558 — Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling
Code review finding: `client/ui/dialogue_box.gd` directly mutates `GameState.dialogue_active` at 3 call sites (lines ~289, ~321, ~334) and calls `AudioManager.apply_dip()` / `AudioManager.clear_dip()` directly.
Per D-020, UI components should not mutate shared state or call sibling autoloads directly — they should emit signals and let a coordinator (main.gd or a future SnapshotEventRouter) manage cross-component state.
What this ticket must deliver:
- Replace the 3 `GameState.dialogue_active = true/false` assignments with a signal: `signal dialogue_state_changed(active: bool)`. `main.gd` connects to this signal and updates `GameState.dialogue_active`.
- Replace `AudioManager.apply_dip("dialogue")` and `AudioManager.apply_dip("confrontation")` / `AudioManager.clear_dip()` calls with signals: `signal audio_dip_requested(profile: String)` and `signal audio_dip_cleared()`. `main.gd` connects to these and calls `AudioManager`.
- Result: `dialogue_box.gd` has zero references to `GameState` or `AudioManager`.
- Unit tests: mock signal receivers capture the emitted signals with correct arguments; no direct autoload calls remain.
Integration points: `client/ui/dialogue_box.gd` (source), `client/scripts/main.gd` (connects to new signals in `_ready()`). No server changes.
Gotcha: `InputMapper` checks `GameState.dialogue_active` to suppress movement. The signal path adds one frame of latency — verify that the signal fires synchronously within the same frame (use `call_immediate` or connect with `CONNECT_DEFERRED` depending on timing requirements). The `is_dialogue_active()` method on `dialogue_box.gd` (line 337) can remain as a local query without touching `GameState`.
### #559 — Refactor: main.gd god coordinator — extract SnapshotEventRouter
Code review finding: `client/scripts/main.gd` is 517 lines and dispatches to 15+ child nodes through a set of `consume_*` methods that all follow the same pattern: read field from snapshot, call method on child node.
What this ticket must deliver:
- Extract a `SnapshotEventRouter` class (`client/scripts/snapshot_event_router.gd`): takes the snapshot dictionary and routes each field to the correct child node via a registered handler map.
- Registration pattern: `router.register("monologue", monologue_display.consume_monologue)` — callable-based dispatch. Handlers are registered in `main.gd`'s `_ready()`.
- `main.gd` `_process()` calls `router.dispatch(snapshot)` instead of 15+ individual `if snapshot.has("X"): child.consume_X()` blocks.
- `main.gd` retains scene tree ownership (`@onready` node references), camera logic, and input handling — the router only handles snapshot dispatch.
- After: `main.gd` should be under 350 lines.
- Unit tests: construct a `SnapshotEventRouter` with mock handlers, dispatch a snapshot, assert each handler received the correct field value.
Integration points: `client/scripts/main.gd` (refactor target), new file `client/scripts/snapshot_event_router.gd`. No server changes, no protocol changes.
Gotcha: Some consume methods in `main.gd` have cross-field dependencies (e.g., camera position depends on both `player_position` and `_camera_anchored` state). Identify these upfront and keep them in `main.gd` directly — only pure per-field dispatch moves to the router. Do not force all logic into the router pattern.
### #560 — Refactor: unify duplicate YAML parsers
Code review finding: `client/scripts/checklist/checklist_evaluator.gd` contains its own YAML parser that partially duplicates `client/scripts/autoloads/ui_strings.gd`'s `_parse_yaml()` method.
What this ticket must deliver:
- Extract a shared `YamlParser` utility class at `client/scripts/util/yaml_parser.gd` (create the `util/` directory).
- `YamlParser` exposes a static method `parse(text: String) -> Dictionary` that handles the common subset of YAML used across both call sites (key: value pairs, nested maps, arrays).
- Replace `checklist_evaluator.gd`'s inline parser with `YamlParser.parse()`.
- Replace `ui_strings.gd`'s `_parse_yaml()` with `YamlParser.parse()` (or delegate to it, keeping the method signature stable).
- Unit tests: parse a sample YAML string with nested keys, arrays, and string values; assert round-trip correctness.
Integration points: `client/scripts/checklist/checklist_evaluator.gd`, `client/scripts/autoloads/ui_strings.gd`, new `client/scripts/util/yaml_parser.gd`. No server changes.
Gotcha: The two existing parsers may handle edge cases differently. Write the unit tests first against both parsers to document their current behavior, then unify. Prioritize correctness for existing content files (`client/data/ui-strings.yaml` and any checklist YAML files) — do not break live content.
## Dependency Chain
```
#557 (game_state derived state) ─┐
#558 (dialogue_box coupling) ├─ all parallel, no inter-dependency
#559 (main.gd SnapshotEventRouter)│ #558 feeds into #559 (signal wiring in main.gd)
#560 (unify YAML parsers) ─┘
```
#558 should complete before #559 so that the new signals from dialogue_box are wired into `main.gd` as part of the router work, not as a separate pass. Otherwise all four tickets run in parallel.
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(client): save/load UI and code quality refactors" \
--description "body" --base main --head client
```
+74
View File
@@ -0,0 +1,74 @@
# Sprint 20: Shape — Joint Coordination
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
## Pre-Sprint Decisions
No blocking pre-sprint decisions are required. All selected tickets have their upstream decisions confirmed.
| Decision | Status | Impact |
|----------|--------|--------|
| D-087 (v0.1 triangle configuration) | Confirmed | Server #106/#107/#250 must produce T1-T5 triangle types |
| D-089 (self-contained forks, no cascade) | Confirmed | No cross-triangle state in `TriangleDef` or `TriangleState` |
| D-025 (social site as atomic template unit) | Confirmed | Server #163/#164/#165 schema shapes |
| D-020 (Godot = pure renderer) | Confirmed | Client refactors #557-#560 are motivated by this |
**One open question to monitor:**
- **Q-028 (line ID collision)** — resolved by D-084 (dual-namespace scheme), but the `RoleCounter` implementation is referenced in D-084 as a requirement for `server/src/content/npc_slug.rs`. Ticket #163 (role definition schema) should create the `server/data/templates/` directory structure; confirm with server team whether the slug counter module belongs in this sprint or the next.
## Sprint Completion Proof
The sprint is done when all of the following are observable:
1. **Template schema compiles and round-trips**: `cargo test -p server -- template` passes. A YAML file at `server/data/templates/sample_role.yaml` deserializes cleanly into a `RoleSchema` struct and re-serializes with identical content.
2. **Triangle generation produces valid state**: `cargo test -p server -- triangle` passes. A 4-NPC test world with 2 `TriangleDef` entries produces 2 `TriangleState` components with valid role assignments and tension values within the configured range.
3. **Triangle escalation fires events**: A unit test simulates 60 ticks on a triangle configured to escalate at tick 50, asserts `TriangleCrisisEvent` was emitted at the correct tick.
4. **Single-ownership model serializes**: A world with 2 templates and a cross-reference survives a save/load round-trip: `TemplateOwnership` components and `TemplateReferenceMap` entries are identical before and after.
5. **Refactors don't regress tests**: `make ci-client` passes with #557-#560 merged. `game_state.apply_snapshot()` contains no conditional accumulation logic. `dialogue_box.gd` has zero direct references to `GameState` or `AudioManager`. `main.gd` is under 350 lines.
6. **District layout design decided**: #153 produces a confirmed D-record specifying: complete district topology (how terminal, bar, smuggling corridors, and gate connect), tile dimensions per zone, access topology, and sightline constraints. Unblocks #155 (hand-crafted location authoring) and #188 (triangle instantiation).
## Test Plan Alignment (D-030)
Sprint 20 is Phase 3+ territory (D-030 sub-decision #8: Phase 3 = sprint 5+: CauseChain verification + divergent snapshots). The new template/triangle system introduces the first simulation structures that will eventually require CauseChain verification.
| Ticket | Test scope | Priority |
|--------|------------|----------|
| #163 | Unit: YAML round-trip, constraint validation | High |
| #164 | Unit: spec validation, tile count range | High |
| #165 | Unit: ownership component, reference map | High |
| #106 | Unit: triangle YAML round-trip, conflict validation | High |
| #107 | Unit: constraint satisfaction, minimum 2 triangles | High |
| #250 | Unit: escalation timing, crisis event emission, resolve command | High |
| #557-#560 | Regression: existing test suite must remain green | Medium |
| #153 | Design discussion: district layout confirmed as D-record | High |
The triangle system's `TriangleCrisisEvent` is the first event candidate for CauseChain integration. Do not wire CauseChain this sprint — but structure the event type so it can carry a `CauseChain` field in a future sprint without breaking callsites.
## Cross-Team Integration Points
| Server ticket | Client dependency | Notes |
|---------------|-------------------|-------|
| #165 (`TemplateOwnership` serialization) | None this sprint | Adds fields to `SaveStateV1` — no client protocol change needed until template data is rendered |
| #250 (`TriangleCrisisEvent` in `ObserverSnapshot`) | None this sprint | Event added to snapshot schema as stub — client rendering of triangle state is Sprint 21+ |
| Planning ticket | Downstream impact | Notes |
|-----------------|-------------------|-------|
| #153 (district layout design) | Unblocks #155, #188 | Layout decisions feed into Sprint 21 hand-crafted location authoring and triangle instantiation |
No live cross-team protocol dependencies this sprint. Server and client work in parallel.
## Deferred to Sprint 21
The following tickets are natural Sprint 21 candidates once this sprint's foundation lands:
- **#166** (Template-to-instance mapping) — instantiate templates into the world; requires #163+#164+#165
- **#161** (Template instantiation engine) — full NPC spawn from template; requires #163+#164+#165+#166
- **#159** (Tier 2 template definition format) — YAML schema for the full template document; blocked by #163
- **#108** (Cross-template triangle generation) — requires #106+#107
- **#109** (Triangle validation) — quality checks on generated triangles; requires #107
- **#155** (Hand-crafted location authoring) — requires #153 (station district layout design), which is being resolved this sprint on the planning branch
+82
View File
@@ -0,0 +1,82 @@
# Sprint 20: Shape — Planning Tasks
**Goal:** Resolve the station district layout design through structured discussion, producing a confirmed D-record that unblocks Sprint 21 location authoring and triangle instantiation.
**Branch:** `planning`
**Agents:** Gestalt (systems design), Miri (worldbuilding), Araminta (visual/spatial), Tyre (technical feasibility), Paula (narrative), Ozzie (player experience), Qatux (documenter), SI (project manager)
## Tickets
| # | Title | Type | Blocks |
|---|-------|------|--------|
| #153 | Station district layout design | design discussion | #155, #188 |
## Discussion Format
Ticket #153 is a **design discussion** — workshop-style, run on the planning branch. The output is a confirmed decision record (D-record) in `decisions/content.md` or `decisions/architecture.md`.
### Context: What Already Exists
Three spatial layouts have been authored (all by Araminta, Sprint 17):
- **The Terminal** (logistics hub): `docs/design/spatial-layout-terminal-v01.md` — 44×28 tiles, cool grey-navy
- **The Last Shift** (bar): `docs/design/spatial-layout-bar-v01.md` — 28×22 tiles, warm dark amber
- **Smuggling corridors**: `docs/design/spatial-layout-smuggling-corridors-v01.md` — overlay on terminal + bar + maintenance corridors
Station profile: `docs/design/sova-station-profile.md` — defines 6 districts, only Transit District is playable in v0.1.
Key decisions already confirmed:
- D-025: Social site / functional cluster as atomic template unit
- D-036: Sova Transit District / Krenn System as v0.1 setting
- D-050: Velen naming and climate
Open question: Q-036 (district skeleton as generator output) — relevant but not blocking; the v0.1 district is hand-authored.
### What #153 Must Decide
The individual locations exist as standalone layouts. What's missing is **how they connect** — the district as a whole:
1. **District topology**: How do the terminal, bar, gate corridor cluster, and smuggling hideout spaces relate spatially? What corridors connect them? What's the walking distance/time between key locations?
2. **Gate corridor cluster** (#157): The span gate area — customs, cargo staging, commuter flow. This is the district's entry point and a social chokepoint. Needs spatial spec at the same fidelity as the terminal and bar.
3. **Access topology**: Public → semi-restricted → restricted zones. How does the access gradient map across the whole district? Where are the boundaries the player must navigate?
4. **Sightline constraints**: Which locations have line-of-sight to which? This is gameplay-critical — the player's observation opportunities depend on where they can see from where.
5. **NPC traffic patterns**: How do NPCs flow through the district? Shift changes, commuter routes, social gathering patterns. The spatial layout determines what the player can observe by being in the right place at the right time.
6. **Total district dimensions**: What's the bounding box? How does tile count affect performance (server spatial queries, client rendering)?
### Discussion Rounds
**Round 1 — Inventory and constraints**
Each agent reviews the existing layouts and states what their domain requires from the district layout. Gestalt: gameplay loops that need spatial support. Miri: setting consistency, what the station profile implies. Araminta: visual continuity across zones, tilemap feasibility. Tyre: performance constraints, tilemap size limits. Paula: narrative beats that need specific spatial staging. Ozzie: navigation feel, does the district feel explorable and readable.
**Round 2 — Topology proposals**
Propose concrete district maps (ASCII or description). How do the existing layouts connect? Where does the gate corridor go? What fills the space between authored locations?
**Round 3 — Convergence**
Resolve conflicts, pick a topology, specify dimensions. Draft the D-record.
### Output
- A confirmed D-record specifying:
- District topology diagram (which locations connect to which, via what corridors)
- Approximate tile dimensions per zone and total district
- Access topology (public/semi-restricted/restricted gradient)
- Key sightline relationships
- Gate corridor cluster spatial spec (or a separate ticket if too large)
- Updated `decisions/` domain file
- Gate corridor layout doc at `docs/design/spatial-layout-gate-v01.md` if produced
### Reference Files
Read before starting:
- `docs/design/spatial-layout-terminal-v01.md`
- `docs/design/spatial-layout-bar-v01.md`
- `docs/design/spatial-layout-smuggling-corridors-v01.md`
- `docs/design/sova-station-profile.md`
- `decisions/content.md` — D-025 (social sites), D-036 (Sova setting)
- `decisions/architecture.md` — D-014 (tile-based movement)
- `decisions/perception.md` — D-059 (fog layers, zone temperature)
- `decisions/questions.md` — Q-036 (district skeleton as generator output)
+147
View File
@@ -0,0 +1,147 @@
# Sprint 20: Shape — Server Tasks
**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 19
None. Sprint 19 treated as complete.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #163 | Role definition schema | — |
| #164 | Spatial requirement specification | — |
| #165 | Single-ownership model | — |
| #106 | Triangle definition schema | — |
| #107 | Intra-template triangle generation | — |
| #250 | Triangle escalation system | — (#103, #105 done) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/content.md` — D-023 (three-tier content model: Tier 1 drama modules, Tier 2 templates, Tier 3 procedural), D-024 (NPC generation model: 10 axes, triangles as atomic social unit — 2 per template minimum), D-025 (social site / functional cluster as atomic template unit: 4-8 NPCs, 15-40 tiles, single-ownership with reference links), D-029 (population entanglement ratio: 30/50/20 — triangles are the 50% mundane layer)
- `decisions/scope.md` — D-087 (v0.1 triangle configuration: T1 Kael-Smuggler-Ring, T2 Sera-Detective-Commission, T4 Drin-System-Ring as active forks; T3 and T5 as passive tensions), D-089 (self-contained triangle forks for v0.1, no cross-triangle cascade)
- `decisions/architecture.md` — D-010 (deterministic simulation: BTreeMap for all collections, no HashMap), D-026 (simulation tiers: Active-tier NPCs are fully simulated; template instantiation populates Active tier), D-041 (KnowledgeGraph: per-entity component — template instantiation must assign KnowledgeGraph to each spawned NPC)
## Notes
### #163 — Role definition schema
The `RoleDefinition` struct already exists in `server/src/npc/generate.rs` as a procedural generation input — it defines `name`, location pool entries, and per-axis ranges. This ticket extends that to become the canonical Tier 2 role schema.
What this ticket must deliver:
- A `RoleSchema` type (new, distinct from `RoleDefinition`) in a new `server/src/content/template/` module (or `server/src/content/types.rs` extended). Fields: `role_id: RoleId` (newtype over String), `required_traits: Vec<PersonalityTrait>`, `skill_focus: Vec<Skill>`, `relationship_constraints: Vec<RelationshipConstraint>`, `routine_template: Vec<RoutineEntry>` — these are constraints fed into the NPC generator, not hardcoded values.
- A `RelationshipConstraint` type: `{ with_role: RoleId, kind: RelationshipKind, required_trust: TrustRange }`. Constrains who this role must be in relationship with within the same template.
- YAML deserialization via `serde`. Schema files will live at `server/data/templates/` (create the directory).
- Unit tests: round-trip YAML serialize/deserialize a sample role schema. Validate constraint logic (no self-referential constraints, no duplicate role_id within a template).
Integration points: `server/src/npc/generate.rs` (`RoleDefinition` → becomes a builder derived from `RoleSchema`), `server/src/content/types.rs` (existing content type infrastructure), `server/src/knowledge/types.rs` (`StableId`, `RelationshipKind`).
Gotcha: `RoleId` must be stable across save/load — it's a string slug, not a bevy `Entity`. Keep it a newtype over `String` so it serializes cleanly with `StableId`.
### #164 — Spatial requirement specification
No existing spatial specification type exists. This is greenfield within the template system.
What this ticket must deliver:
- A `SpaceSpec` type: `{ tile_count_min: u32, tile_count_max: u32, sightline_zones: Vec<SightlineZone>, privacy_level: PrivacyLevel, traffic_pattern: TrafficPattern }`.
- `SightlineZone`: a named sub-area with a coverage radius in sim tiles (0.5m each, per D-066). Example: `{ name: "bar_counter", radius: 4 }` — 4 sim tiles = 2m clear sightline.
- `PrivacyLevel` enum: `Public`, `SemiPrivate`, `Private`. Governs NPC behavior (NPCs are less likely to disclose secrets in Public spaces).
- `TrafficPattern` enum: `Thoroughfare`, `Destination`, `Restricted`. Governs procedural NPC routine routing through this space.
- YAML deserialization. Schema files co-locate with role schemas at `server/data/templates/`.
- Unit tests: sample spec round-trip, validation that min <= max tile count.
Integration points: `server/src/content/template/` (new module or extended `server/src/content/types.rs`), future chunk generation (`server/src/simulation/` — spatial specs will inform where templates are placed in the map). No simulation code changes needed this sprint — spec types only.
Gotcha: Tile counts are in sim tiles (0.5m). A 15-40 visual tile space (per D-025) = 30-80 sim tiles. Document this conversion explicitly in code comments to prevent future confusion.
### #165 — Single-ownership model
NPCs are owned by exactly one template, with reference links to others (D-025). No existing ownership component exists.
What this ticket must deliver:
- A `TemplateOwnership` ECS component: `{ template_id: TemplateId, role_id: RoleId }`. Assigned at template instantiation, never reassigned.
- A `TemplateId` newtype over `u64` — deterministic from world seed + template slug hash.
- A `TemplateReference` struct: `{ from_template: TemplateId, to_template: TemplateId, via_role: RoleId, relationship_metadata: RelationshipKind }`. Stored in a `TemplateReferenceMap` resource (a `BTreeMap<TemplateId, Vec<TemplateReference>>`).
- Logic for lifecycle coordination: when a template is unloaded (NPC tier drops to State-saved or Ungenerated per D-026), `TemplateReference` links are preserved in the serialized state, not destroyed.
- Unit tests: spawn two templates with cross-references, verify `TemplateReferenceMap` entries, verify `TemplateOwnership` components.
Integration points: `server/src/simulation/tier.rs` (tier transitions must preserve `TemplateOwnership`), `server/src/simulation/save_state.rs` (serialize `TemplateOwnership` and `TemplateReferenceMap` as part of `SaveStateV1` — add fields), `server/src/npc/generate.rs` (generator receives `TemplateId` + `RoleId` at spawn time).
Gotcha: `TemplateId` from seed + slug hash must be deterministic across save/load — use `StdHasher` is prohibited (non-deterministic), use a seeded hash (e.g., `std::hash::Hasher` from a fixed algorithm) or simply hash the slug string bytes with a fixed polynomial. Log the `TemplateId` computed value in tests for debugging.
### #106 — Triangle definition schema
The D-024 spec says triangles are the atomic unit of social intrigue — 2 per template minimum, 1 cross-template. No `TriangleDef` type exists anywhere in the codebase.
What this ticket must deliver:
- A `TriangleDef` type: `{ triangle_id: TriangleId, roles: [RoleId; 3], conflict_type: ConflictType, interest_axes: [NpcAxis; 3], relationship_constraints: Vec<RelationshipConstraint> }`. Three roles, each with a conflicting axis (Want, Secret, Tolerance, etc.).
- `ConflictType` enum based on D-087 active fork patterns: `ResourceCompetition`, `LoyaltyConflict`, `SecretExposure`, `AuthorityChallenge`. Passive tensions use `LatentTension` variant.
- `TriangleId` newtype over `u64` — deterministic from template seed + role triple.
- Validation: all three roles must be distinct within the template; the conflict type must map to at least one axis divergence (no conflict on identical axis values).
- YAML deserialization. Triangle definitions are authored as part of a template file or as a standalone `triangles.yaml` per template — Dudley to decide the co-location approach.
- Unit tests: sample triangle round-trip, validation for duplicate roles, validation for self-consistent conflict.
Integration points: `server/src/content/template/` (lives alongside `RoleSchema` and `SpaceSpec`), `server/src/npc/generate.rs` (the generator will consume `TriangleDef` in #107 to assign axis values that produce the desired conflict), `decisions/content.md` D-087 (v0.1 triangles T1-T5 should be expressible in this schema).
Gotcha: D-089 — self-contained triangles for v0.1, no cross-triangle cascade. Do not add cross-triangle state fields to `TriangleDef`. Cross-template triangles are expressed by a `TriangleDef` that references a `RoleId` from a different `TemplateId` — the cross-template link is in the role, not a special triangle type.
### #107 — Intra-template triangle generation
The template system can now describe triangles (#106). This ticket generates them from the description.
What this ticket must deliver:
- A `generate_intra_template_triangles(world: &mut World, template_id: TemplateId, defs: &[TriangleDef], rng: &mut SimRng) -> Vec<TriangleState>` function.
- `TriangleState` ECS component: `{ triangle_id: TriangleId, role_assignments: BTreeMap<RoleId, StableId>, tension: u8, phase: TrianglePhase }`. `tension` starts at a seeded value within a configured range. `TrianglePhase` enum: `Dormant`, `Simmering`, `Active`, `Resolved`.
- Constraint satisfaction: for each `TriangleDef`, assign generated NPCs (by `StableId`) to the three roles. Validate that the NPC's axis values satisfy the conflict (e.g., for a `LoyaltyConflict`, the NPC filling the `loyalty_torn` role must have a Relationships axis with entries for both of the other two roles).
- Minimum 2 triangles per template — emit an error (not a panic) if the template definition provides fewer than 2 `TriangleDef` entries.
- Unit tests: spawn a 4-NPC template, generate 2 triangles, assert `TriangleState` components exist and role assignments are valid, assert constraint satisfaction.
Integration points: `server/src/npc/generate.rs` (NPC generation runs first; triangle generation consumes the generated NPCs' axis values), `server/src/content/template/` (#106 types), `server/src/simulation/rng.rs` (`SimRng` for determinism).
Gotcha: Constraint satisfaction can fail if the NPC pool doesn't provide a suitable candidate for a role. Implement a fallback: if no NPC satisfies the strict constraint, pick the closest match and log a warning. Do not panic — world generation must be robust to imperfect seeds.
### #250 — Triangle escalation system
Blockers #103 (relationship dynamics) and #105 (tolerance threshold triggers) are done. `TriangleState` from #107 is available this sprint.
What this ticket must deliver:
- An ECS system `tick_triangle_escalation` that runs once per game-minute (every 10 ticks per D-031). For each `TriangleState` in `Simmering` or `Active` phase: increment `tension` by a seeded per-triangle rate (drawn from `SimRng` at world-gen time, stored on `TriangleState`). When `tension` exceeds the lowest `ToleranceThreshold` among the triangle's three NPCs, transition `phase` from `Simmering` to `Active`.
- Observable events: when a triangle enters `Active`, emit a `TriangleCrisisEvent` (new event type) containing `triangle_id`, `role_assignments`, and `trigger_npc: StableId`. The monologue system and knowledge system can subscribe to this event — but do not wire those subscribers this sprint. Emit the event; downstream consumption is future work.
- `Resolved` transition: when the player resolves an active fork (mechanism TBD — stub a `ResolveTriangle(TriangleId)` command for now), set `phase = Resolved`. D-089: resolution does not cascade.
- Unit tests: simulate 60 ticks on a triangle with a known tension rate, assert `Active` transition at the expected tick. Test `Resolved` command sets phase correctly.
Integration points: `server/src/simulation/tier.rs` (`tick_triangle_escalation` only runs on Active-tier NPCs per D-026), `server/src/simulation/time.rs` (game-minute scheduler — 10-tick interval), `server/src/npc/tolerance.rs` (`ToleranceThreshold` component), `server/src/npc/relationships.rs` (`RelationshipGraph` — tension rate influenced by relationship stress), `server/src/bridge/types.rs` (add `TriangleCrisisEvent` to `ObserverSnapshot` for future client rendering).
Gotcha: Different seeds produce different tolerance thresholds — the same triangle template can escalate in 5 minutes or 30 minutes depending on the seed. This is intentional (D-087). Do not hardcode a tension rate — it must come from `SimRng` at world-gen time and be stored on the component.
## Dependency Chain
```
#163 (Role definition schema) ─┐
#164 (Spatial requirement spec) ├─ parallel, no inter-dependency
#165 (Single-ownership model) ─┘
└─ feeds #166 (Template-to-instance mapping, Sprint 21)
#106 (Triangle definition schema) ──► #107 (Intra-template generation) ──► #250 (Escalation system)
└─ feeds #108 (Cross-template generation, Sprint 21)
```
#163/#164/#165 and #106/#107/#250 are two parallel tracks. All six tickets can begin in week 1; #107 and #250 gate on #106 completing first.
## PR Workflow
When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): social site template schema and triangle system" \
--description "body" --base main --head server
```
@@ -0,0 +1,142 @@
# Generator Architecture Workshop Brief
**Goal:** Establish the top-down procedural generator pipeline architecture — from geography down to individual chunk fill — that will power the 300-world model. Produce a D-record defining generator primitives, the spatial hierarchy, and the sub-chunk building system.
**Ticket:** #562 (story, parent: #50 Chunk-based Map System)
**Priority:** MEDIUM — prerequisite to #144 (Chunk generation system), Sprint 21+ target
**Participants:** Gestalt (systems design), Tyre (architecture), Miri (worldbuilding), Araminta (spatial/visual), Nigel (replayability/procedural gen), Qatux (docs), SI (tickets)
**Source:** Station District Layout Workshop (#153, Round 3), lead directive
---
## Context
The v0.1 Transit District is hand-authored. The long-game goal is 300 procedurally generated worlds. The question this workshop must answer is: **what is the generator's architecture — top to bottom?**
The lead has identified a top-down pipeline model inspired by Cities Skylines:
```
Geography
→ Infrastructure (transport nodes, utilities)
→ Amenities & Services
→ Population (extrapolated from capacity)
→ Zoning
→ Block generation
→ Chunk fill (individual buildings and spaces)
```
This workshop also addresses a related structural question: how does the **sub-chunk quarter system** provide building variety within template-driven generation? A chunk divides into 4 quarters that can merge, split, leave gaps, or host shacks/gardens — producing L-shapes, mixed-use footprints, and irregular structures without breaking the generator's regularity.
Multi-block structures (train stations, government buildings, stadiums, parks, farmland) span multiple chunks and must be accounted for in the block and zoning passes before individual chunks are filled.
**Starting point:** Q-036 asks whether the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) is the atomic generator output unit. D-025 defines social sites as atomic template units for hand-authoring. This workshop must reconcile the two.
**What is already decided:**
- D-025: Social site / functional cluster as atomic template unit (hand-authoring)
- D-036: Sova Transit District as v0.1 setting (hand-authored)
- D-012: Chunk-based map system (bounded for v0.1, borderless-capable)
- #153 D-record (Station District Layout Workshop): district/block/chunk spatial hierarchy, spatial dimensions, access topology gradient — reference `decisions/content.md` for the confirmed record
**What is still open:**
- Q-036: District skeleton as generator output (assigned Tyre, Gestalt)
- Q-037: Generator development pipeline / phased production model (assigned SI, Tyre)
- Q-039: Procedural gate topology generation
---
## Key Questions to Resolve
### 1. Pipeline Architecture
1. Is the Cities Skylines top-down model (geography → zoning → chunk fill) the right architecture for The Settled Reach, or does the game's station-centric setting require a different ordering?
2. What is the correct sequence — does population follow zoning, or precede it?
3. How does the pipeline handle stations (Sova) vs. planet-side cities vs. orbital installations? Same pipeline, different geography inputs?
4. Where do political/economic conditions enter the pipeline? (Faction control, prosperity tier, trade routes)
5. At what pipeline stage are the triangle templates (D-025) instantiated?
### 2. Spatial Hierarchy and Primitives
6. What are the canonical spatial units in the hierarchy? (Region → District → Block → Chunk → Sub-chunk quarter? Or different names/levels?)
7. What is the confirmed chunk size in sim tiles? (Reference #153 D-record — the district workshop decided this)
8. What is the block size — how many chunks per block?
9. What is the district size — how many blocks per district?
10. How does the sub-chunk quarter system work mechanically? (Quarter = ¼ chunk, can merge 2×2, 1×2, L-shape. What are the merge rules? Who decides fill vs. empty?)
### 3. Multi-Block Structures
11. How are multi-block structures (train stations, stadiums, government complexes, parks) represented in the generator? (Reserved footprint at the zoning pass? Pre-baked templates that claim N×M blocks?)
12. What is the maximum multi-block footprint — is there a cap?
13. How do multi-block structures interact with neighbouring chunk fills at their edges?
14. Can a multi-block structure span district boundaries?
### 4. District Skeleton as Generator Output (Q-036)
15. Is the district skeleton (social site arrangement, NPC slot allocation, triangle template selection, access topology) the correct atomic output of the district-generation stage?
16. How does the district skeleton output interact with D-025's social site templates? (Generator selects and arranges templates, not individual tiles?)
17. What inputs does the district skeleton generator consume? (Zoning type, population density, faction control, economic function, transport adjacency)
18. What does the district skeleton output look like as a data structure? (List of social site slots with positions, access tier, NPC capacity, template tag)
### 5. Replayability and Variation
19. What variation levers exist at each pipeline stage? (Seed, faction weights, economic tier, historical events?)
20. How does the sub-chunk quarter system produce perceived variety across multiple playthroughs?
21. How are "flavour" structures (shacks, gardens, market stalls) assigned to unclaimed quarter space?
22. What prevents two generated districts from feeling identical even if they share the same zoning type?
### 6. v0.1 / Generator Boundary
23. Where does the v0.1 hand-authored content end and the generator begin? What stub interfaces must v0.1 leave behind?
24. Which pipeline stages are in scope for implementation, and which are deferred (per Q-037's phased production model)?
25. Does the v0.1 Transit District need to be expressible as generator output (for validation), or is it purely an authored ground-truth?
---
## Input Documents
| Document | What to read | Why |
|----------|-------------|-----|
| `decisions/content.md` | D-025 (social site template), #153 D-record | Generator must compose from these primitives |
| `decisions/scope.md` | D-012 (chunk system), D-036 (Sova setting) | Spatial constraints and v0.1 setting |
| `decisions/questions.md` | Q-036 (district skeleton), Q-037 (generator pipeline), Q-039 (gate topology) | Open questions this workshop resolves |
| `docs/design/sova-station-profile.md` | District types, 6-district layout | The worldbuilding context the generator must reproduce |
| `docs/design/spatial-layout-terminal-v01.md` | Terminal layout | Example of a hand-authored chunk cluster |
| `docs/design/spatial-layout-bar-v01.md` | Bar layout | Example of a hand-authored chunk cluster |
| `decisions/architecture.md` | D-014 (tile-based movement), D-012 (chunk loading) | Technical constraints on spatial units |
| `docs/workshops/content-architecture/content-architecture-workshop-brief.md` | Three-tier content pipeline | How templates and procedural generation already relate |
---
## Expected Outputs
1. **D-record: Generator Architecture** — confirmed in `decisions/architecture.md`:
- Top-down pipeline stages (named and sequenced)
- Spatial hierarchy (named levels, tile dimensions per level)
- Sub-chunk quarter system rules (merge/split/fill logic)
- Multi-block structure reservation protocol
- District skeleton as generator output: data structure definition
- v0.1 / generator boundary (what's hand-authored, what's stubbed)
2. **Resolution of Q-036:** District skeleton as atomic generator output — yes/no + formal definition if yes
3. **Resolution of Q-037 scope:** Which pipeline phases land in which version window (v0.1 stub, v0.20.5 template expansion, v0.60.10 generator development)
4. **Tickets:** Implementation tasks derived from the D-record (chunk data structure update, district skeleton schema, zoning pass stub, block generation stub)
---
## Workshop Format
Three rounds:
**Round 1 — Domain Inventory**
Each participant reviews existing decisions and states what their domain requires from the generator architecture.
- Gestalt: what gameplay loops does the generator need to support? What must it guarantee (e.g., always a surveillance chokepoint, always a quiet zone)?
- Tyre: what are the hard technical constraints on chunk size, hierarchy depth, and data structure for the district skeleton?
- Miri: how does the generator reproduce the cultural/economic variation of 300 worlds? What lore-level inputs drive the pipeline?
- Araminta: what visual coherence constraints does chunk fill need to satisfy? How does the sub-chunk quarter system produce plausible streetscapes?
- Nigel: what variation and replayability guarantees must the generator provide? What makes two generated districts feel different?
**Round 2 — Pipeline Proposals**
Propose concrete pipeline architecture. Name the stages, define the spatial hierarchy levels with tile dimensions, describe the district skeleton data structure. Respond to each other's domain requirements from Round 1.
**Round 3 — Convergence**
Resolve conflicts, agree on the pipeline sequence, lock spatial hierarchy dimensions, define the district skeleton output format, set the v0.1/generator boundary. Draft the D-record.
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.19
version: 0.1.20
repository: settled-reach
codename: commonwealth
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.15"
version = "0.1.19"
dependencies = [
"bevy_app",
"bevy_ecs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.19"
version = "0.1.20"
edition = "2021"
[dependencies]
+31
View File
@@ -0,0 +1,31 @@
# Sample role schema for the dock-worker role in the terminal social site.
# Spec ref: D-023 (Tier 2 templates), D-024 (10-axis model), D-025 (social site)
#
# Tile scale note (D-066): tile_count_* fields are in SIM tiles (0.5m each).
# 1 visual tile = 2 sim tiles. A 15-40 visual tile space = 30-80 sim tiles.
role_id: "dock-worker"
required_traits:
- Honest
- Social
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "logistics-manager"
kind: Colleague
required_trust:
min: -2
max: 4
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -4
max: 0
routine_template:
- phase: morning
location: "terminal-cargo-bay"
- phase: afternoon
location: "terminal-cargo-bay"
- phase: evening
location: "bar-last-shift"
@@ -0,0 +1,18 @@
# Sample space spec for the terminal social site (Sova Logistics Hub).
# Spec ref: D-025 (15-40 visual tiles = 30-80 sim tiles), D-064 (dual-scale grid)
#
# Tile scale (D-066): all tile counts are SIM tiles (0.5m each).
# Terminal: 44×28 visual tiles = 88×56 sim tiles = 4928 sim tiles.
# Using a subsection for one functional zone: ~44×8 visual = 88×16 sim = 1408 sim.
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "loading-floor"
radius: 8 # 4m clear sightline across the loading area
- name: "reception-desk"
radius: 4 # 2m clear sightline at the desk
- name: "cargo-staging"
radius: 6 # 3m clear sightline in staging area
privacy_level: SemiPrivate
traffic_pattern: Destination
@@ -0,0 +1,24 @@
# Sample triangle definition: T4 Drin-System-Ring (D-087 active fork).
# Spec ref: D-024 (triangles as atomic social unit), D-087 (T4 configuration),
# D-089 (self-contained, no cross-triangle cascade)
#
# Note: triangle_id is computed at world-gen time from seed + roles.
# The value below is a placeholder for YAML authoring — the runtime
# calls TriangleId::from_seed_and_roles() to derive the actual ID.
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
+2
View File
@@ -313,6 +313,7 @@ mod tests {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -448,6 +449,7 @@ mod tests {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+40 -2
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 15;
pub const PROTOCOL_VERSION: u8 = 16;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -50,10 +50,11 @@ pub struct HandshakeMessage {
/// examine_result (#242, character-filtered examine observation text),
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// v15 adds: save_result (#553, save/load operation result for client confirmation).
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 15.
/// Protocol version for forward compatibility. Current: 16.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -151,6 +152,11 @@ pub struct ObserverSnapshot {
/// Client shows a confirmation toast (success) or error modal (failure).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_result: Option<SaveLoadResultWire>,
/// Triangle crisis events this tick (#250, D-087).
/// Emitted when a triangle enters Active phase. Client may render
/// a narrative event or HUD indicator. Empty when no crises occur.
#[serde(default)]
pub triangle_crisis_events: Vec<TriangleCrisisEventWire>,
}
/// Game time data for client display (D-031)
@@ -676,6 +682,38 @@ pub struct SaveLoadResultWire {
pub error: Option<String>,
}
/// Triangle crisis event for future client rendering (#250, D-087).
///
/// Emitted when a triangle transitions from Simmering to Active.
/// Client may display a narrative beat, HUD indicator, or tension meter.
/// Wire format uses primitives for cross-boundary safety.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriangleCrisisEventWire {
/// Triangle identifier (TriangleId as u64).
pub triangle_id: u64,
/// NPC role assignments: (role_slug, stable_npc_id).
pub role_assignments: Vec<(String, u64)>,
/// The NPC whose tolerance threshold triggered the crisis.
pub trigger_npc_id: u64,
/// Tick when the crisis was triggered.
pub tick: u64,
}
impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEventWire {
fn from(e: crate::content::template::TriangleCrisisEvent) -> Self {
Self {
triangle_id: e.triangle_id.into(),
role_assignments: e
.role_assignments
.into_iter()
.map(|(role, sid)| (String::from(role), u64::from(sid)))
.collect(),
trigger_npc_id: e.trigger_npc.into(),
tick: e.tick,
}
}
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+1
View File
@@ -14,6 +14,7 @@ pub mod hot_reload;
pub mod line_pool;
pub mod loader;
pub mod spawn;
pub mod template;
pub mod types;
use bevy_app::prelude::*;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -18,6 +18,12 @@ use crate::simulation::movement::TilePosition;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct StableId(pub u64);
impl From<StableId> for u64 {
fn from(id: StableId) -> Self {
id.0
}
}
/// Typed fact identifier for non-entity knowledge.
/// Format: "category.topic" (e.g., "contraband.ring_exists").
/// Lexicographic ordering in BTreeMap provides deterministic iteration.
+1
View File
@@ -213,6 +213,7 @@ mod tests {
world.init_resource::<ObservationEventQueue>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+1
View File
@@ -168,6 +168,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+10
View File
@@ -27,6 +27,7 @@ use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::poi::PointOfInterest;
use crate::simulation::rng::SimRng;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::sound::SoundEventQueue;
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
@@ -99,6 +100,7 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
mut crisis_queue: ResMut<TriangleCrisisEventQueue>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
@@ -387,6 +389,13 @@ pub fn compute_observer_snapshot(
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
// Drain triangle crisis events (#250) and convert to wire format.
let triangle_crisis_events = crisis_queue
.drain()
.into_iter()
.map(TriangleCrisisEventWire::from)
.collect();
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -414,6 +423,7 @@ pub fn compute_observer_snapshot(
examine_result,
player_knowledge,
save_result,
triangle_crisis_events,
});
}
+1
View File
@@ -16,6 +16,7 @@ fn setup_world(width: i32, height: i32) -> World {
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world.init_resource::<crate::simulation::sound::SoundEventQueue>();
world.init_resource::<crate::content::template::TriangleCrisisEventQueue>();
world
}
+11
View File
@@ -51,6 +51,9 @@ impl Plugin for SimulationPlugin {
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// Triangle escalation resources (#250)
.init_resource::<crate::content::template::TriangleCrisisEventQueue>()
.init_resource::<crate::content::template::ResolveTriangleQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
.init_resource::<crate::perception::query::VisibilityGeometry>()
@@ -96,6 +99,14 @@ impl Plugin for SimulationPlugin {
pressure::update_character_pressure
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
crate::content::template::tick_triangle_escalation
.after(crate::npc::tolerance::check_tolerance_threshold)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle resolution (#250, D-089) — apply player resolve commands
crate::content::template::apply_resolve_triangle
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+26
View File
@@ -16,6 +16,7 @@ use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::content::template::{TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
@@ -105,6 +106,20 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
npc_states.sort_by_key(|s| s.stable_id.0);
let npc_count = npc_states.len();
// Capture TemplateReferenceMap if present — default to empty if not yet initialised.
let template_references = world
.get_resource::<TemplateReferenceMap>()
.cloned()
.unwrap_or_default();
// Capture TriangleState components — sorted by triangle_id for determinism (D-010).
let mut triangle_states: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(world).cloned().collect()
};
triangle_states.sort_by_key(|t| t.triangle_id.0);
let state = SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick,
@@ -113,6 +128,8 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
player_knowledge,
relationship_graph,
npc_states,
template_references,
triangle_states,
};
let bytes = state
@@ -200,6 +217,12 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
// Restore simulation resources.
world.insert_resource(state.relationship_graph);
world.insert_resource(state.template_references);
// Restore triangle states (#250) — spawn dedicated entities for each.
for ts in &state.triangle_states {
world.spawn(ts.clone());
}
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = state.tick;
@@ -487,6 +510,7 @@ mod tests {
#[test]
fn load_from_file_rejects_wrong_format_version() {
use crate::content::template::TemplateReferenceMap;
// Craft a save with a wrong format_version
let bad_state = SaveStateV1 {
format_version: 0xFF, // deliberately wrong
@@ -496,6 +520,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
+30
View File
@@ -39,6 +39,7 @@ use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
@@ -83,6 +84,16 @@ pub struct SaveStateV1 {
/// Per-NPC summary state for each simulated NPC.
/// Order is deterministic (sorted by stable_id in ascending order).
pub npc_states: Vec<NpcSaveState>,
/// Cross-template reference links (#165).
/// Preserved across save/load so that tier-evicted templates retain their
/// relationship metadata even when their NPCs are not in Active tier.
#[serde(default)]
pub template_references: TemplateReferenceMap,
/// Triangle escalation states (#250).
/// Persisted so tension/phase survive save/load. Sorted by triangle_id
/// for deterministic serialization (D-010).
#[serde(default)]
pub triangle_states: Vec<TriangleState>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
@@ -184,6 +195,12 @@ pub struct NpcSaveState {
/// Job performance score — drifts over time, persist across tier transitions.
#[serde(default)]
pub job_performance: Option<JobPerformance>,
/// Template ownership (#165): which template owns this NPC and which role it fills.
/// `None` for NPCs that predate the template system or were hand-authored without
/// template assignment. Preserved across tier transitions (D-025 single-ownership).
#[serde(default)]
pub template_ownership: Option<TemplateOwnership>,
}
impl SaveStateV1 {
@@ -258,6 +275,7 @@ pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState {
combat_capability: world.get::<CombatCapability>(entity).cloned(),
mood_state: world.get::<MoodState>(entity).cloned(),
job_performance: world.get::<JobPerformance>(entity).cloned(),
template_ownership: world.get::<TemplateOwnership>(entity).cloned(),
}
}
@@ -353,6 +371,10 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
if let Some(combat) = state.combat_capability.clone() {
em.insert(combat);
}
// Restore template ownership if present — never reassigned after initial spawn (D-025).
if let Some(ownership) = state.template_ownership.clone() {
em.insert(ownership);
}
}
entity
@@ -381,6 +403,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
}
}
@@ -449,6 +473,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
NpcSaveState {
stable_id: StableId(202),
@@ -469,6 +494,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
];
@@ -577,6 +603,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
}];
let bytes = state.to_bytes().expect("serialize");
@@ -734,6 +761,7 @@ mod tests {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
let mut world = World::new();
@@ -771,6 +799,8 @@ mod tests {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![frozen],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
};
let bytes = save.to_bytes().expect("serialize");
+1
View File
@@ -72,6 +72,7 @@ fn snapshot_roundtrip_over_unix_socket() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
bridge
+1
View File
@@ -58,6 +58,7 @@ fn snapshot_roundtrip_over_tcp() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
bridge
+3
View File
@@ -48,6 +48,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -239,6 +240,7 @@ fn generate_msgpack_fixtures() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
write_fixture(
"snapshot_v2_full",
@@ -400,6 +402,7 @@ fn generate_msgpack_fixtures() {
acquired_tick: 10,
}],
}),
triangle_crisis_events: vec![],
};
write_fixture(
"snapshot_full",
+2 -1
View File
@@ -73,7 +73,8 @@
"scan_events": [],
"sound_events": [],
"tick": 8,
"version": 15,
"triangle_crisis_events": [],
"version": 16,
"visible_tiles": [
{
"tile_kind": "Wall",
+4
View File
@@ -169,6 +169,7 @@ fn save_state_npc_kg_isolation() {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
// NPC_B (Background tier) does not carry a KG.
@@ -191,6 +192,7 @@ fn save_state_npc_kg_isolation() {
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
let save = SaveStateV1 {
@@ -201,6 +203,8 @@ fn save_state_npc_kg_isolation() {
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![npc_a_state, npc_b_state],
template_references: Default::default(),
triangle_states: vec![],
};
// Roundtrip: serialize → deserialize.
+4 -1
View File
@@ -36,6 +36,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
@@ -294,6 +295,7 @@ fn snapshot_v2_fields_roundtrip() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -348,7 +350,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 15,
PROTOCOL_VERSION, 16,
"bump this assertion when protocol version changes"
);
}
@@ -398,6 +400,7 @@ fn all_facing_direction_variants_roundtrip() {
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
+639
View File
@@ -0,0 +1,639 @@
//! Integration tests for the template schema system (tickets #163, #164, #165, #106).
//!
//! Tests YAML round-trips, validation logic, and ECS component interactions
//! against the spec decisions:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, triangles as atomic unit
//! - D-025: social site / single-ownership model
//! - D-087: v0.1 triangle configuration
//! - D-089: self-contained triangle forks, no cross-triangle cascade
//! - D-010: determinism (no HashMap, FNV-1a IDs)
use settled_reach_server::content::template::{
validate_role_schemas_no_duplicate_ids, ConflictType, NpcAxis, PrivacyLevel,
RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId,
TemplateOwnership, TemplateReference, TemplateReferenceMap, TemplateRoutineEntry,
TrafficPattern, TriangleDef, TriangleId, TrustRange,
};
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_role_schema(id: &str) -> RoleSchema {
RoleSchema {
role_id: RoleId::new(id),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
}
}
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
let role_arr = [
RoleId::new(roles[0]),
RoleId::new(roles[1]),
RoleId::new(roles[2]),
];
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
TriangleDef {
triangle_id,
roles: role_arr,
conflict_type: conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
}
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn role_schema_minimal_yaml_parse() {
let yaml = r#"
role_id: "guard"
skill_focus:
- Combat
- Observation
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
assert_eq!(schema.role_id, RoleId::new("guard"));
assert_eq!(schema.skill_focus.len(), 2);
assert!(schema.required_traits.is_empty());
assert!(schema.relationship_constraints.is_empty());
assert!(schema.routine_template.is_empty());
}
#[test]
fn role_schema_full_yaml_parse() {
let yaml = r#"
role_id: "dock-worker"
required_traits:
- Cautious
- Honest
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -2
max: 2
routine_template:
- phase: "morning"
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: "evening"
location: "bar-last-shift"
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
assert_eq!(schema.required_traits.len(), 2);
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(schema.skill_focus.len(), 2);
assert_eq!(schema.relationship_constraints.len(), 1);
assert_eq!(
schema.relationship_constraints[0].with_role,
RoleId::new("ring-contact")
);
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
assert_eq!(schema.routine_template.len(), 2);
assert_eq!(schema.routine_template[0].phase, "morning");
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
assert_eq!(schema.routine_template[1].activity, None);
}
#[test]
fn role_schema_yaml_roundtrip_preserves_all_fields() {
let schema = RoleSchema {
role_id: RoleId::new("ring-contact"),
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![
RelationshipConstraint {
with_role: RoleId::new("dock-worker"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
},
RelationshipConstraint {
with_role: RoleId::new("ring-leader"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
},
],
routine_template: vec![
TemplateRoutineEntry {
phase: "morning".into(),
location: "terminal-cargo-bay".into(),
activity: Some("oversight".into()),
},
TemplateRoutineEntry {
phase: "evening".into(),
location: "maintenance-corridor".into(),
activity: None,
},
],
};
let yaml = serde_yaml::to_string(&schema).expect("serialize");
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(restored.role_id, schema.role_id);
assert_eq!(restored.required_traits, schema.required_traits);
assert_eq!(restored.skill_focus, schema.skill_focus);
assert_eq!(
restored.relationship_constraints.len(),
schema.relationship_constraints.len()
);
assert_eq!(
restored.relationship_constraints[0].required_trust,
schema.relationship_constraints[0].required_trust
);
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
assert_eq!(
restored.routine_template[0].activity,
schema.routine_template[0].activity
);
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — validation
// ---------------------------------------------------------------------------
#[test]
fn self_referential_constraint_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("dock-worker"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("dock-worker"), // same as role_id
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "self-referential constraint must be rejected");
assert!(
result.unwrap_err().contains("self-referential"),
"error must mention self-referential"
);
}
#[test]
fn invalid_trust_range_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("captain"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "TrustRange min > max must be rejected");
assert!(
result.unwrap_err().contains("trust min"),
"error must mention trust min"
);
}
#[test]
fn collection_with_duplicate_role_ids_rejected() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("dock-worker"), // duplicate
];
let result = validate_role_schemas_no_duplicate_ids(&schemas);
assert!(result.is_err(), "duplicate role_ids must be rejected");
let msg = result.unwrap_err();
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
}
#[test]
fn collection_with_unique_role_ids_ok() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("logistics-manager"),
];
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
}
// ---------------------------------------------------------------------------
// #164: Spatial requirement specification — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn space_spec_minimal_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
assert_eq!(spec.tile_count_min, 30);
assert_eq!(spec.tile_count_max, 80);
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
assert!(spec.sightline_zones.is_empty());
}
#[test]
fn space_spec_full_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "bar-counter"
radius: 4
- name: "corner-booth"
radius: 2
privacy_level: SemiPrivate
traffic_pattern: Destination
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
assert_eq!(spec.sightline_zones.len(), 2);
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
assert_eq!(spec.sightline_zones[0].radius, 4);
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
assert_eq!(spec.sightline_zones[1].radius, 2);
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
}
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
#[test]
fn space_spec_d025_tile_count_range() {
let spec = SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Public,
traffic_pattern: TrafficPattern::Destination,
};
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
}
#[test]
fn space_spec_validation_min_gt_max_fails() {
let spec = SpaceSpec {
tile_count_min: 100,
tile_count_max: 50,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Private,
traffic_pattern: TrafficPattern::Restricted,
};
let result = spec.validate();
assert!(result.is_err(), "min > max must fail validation");
let msg = result.unwrap_err();
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
}
#[test]
fn all_privacy_levels_yaml_roundtrip() {
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
let yaml = serde_yaml::to_string(level).unwrap();
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
}
}
#[test]
fn all_traffic_patterns_yaml_roundtrip() {
for pattern in &[
TrafficPattern::Thoroughfare,
TrafficPattern::Destination,
TrafficPattern::Restricted,
] {
let yaml = serde_yaml::to_string(pattern).unwrap();
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
}
}
// ---------------------------------------------------------------------------
// #165: Single-ownership model — TemplateId determinism
// ---------------------------------------------------------------------------
#[test]
fn template_id_fnv1a_stable_across_calls() {
let id = TemplateId::from_seed_and_slug(0, "");
assert_eq!(
id,
TemplateId::from_seed_and_slug(0, ""),
"empty slug + seed 0 must be stable"
);
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
assert_eq!(
id2,
TemplateId::from_seed_and_slug(42, "the-terminal"),
"non-empty slug must be stable"
);
}
#[test]
fn template_ownership_component_single_owner_invariant() {
// D-025: NPCs are owned by exactly one template, never reassigned.
let seed = 1u64;
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
assert_eq!(ownership.template_id, tid);
assert_eq!(ownership.role_id, rid);
// Clone (as would happen in save-state) must preserve values.
let cloned = ownership.clone();
assert_eq!(cloned.template_id, ownership.template_id);
assert_eq!(cloned.role_id, ownership.role_id);
}
#[test]
fn template_reference_map_preserves_links_on_unload() {
// D-025: reference links must be preserved when a template is unloaded.
let mut map = TemplateReferenceMap::default();
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
map.add(TemplateReference {
from_template: tid_a,
to_template: tid_b,
via_role: RoleId::new("ring-contact"),
relationship_metadata: RelationshipKind::Colleague,
});
// Simulate "unload template-a" by cloning (the save path).
let preserved = map.clone();
assert_eq!(preserved.outgoing(tid_a).len(), 1);
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
}
#[test]
fn template_reference_map_btreemap_deterministic_ordering() {
// D-010: BTreeMap ensures deterministic iteration order.
let mut map = TemplateReferenceMap::default();
let tid_high = TemplateId(u64::MAX - 1);
let tid_low = TemplateId(1);
map.add(TemplateReference {
from_template: tid_high,
to_template: tid_low,
via_role: RoleId::new("role-a"),
relationship_metadata: RelationshipKind::Colleague,
});
map.add(TemplateReference {
from_template: tid_low,
to_template: tid_high,
via_role: RoleId::new("role-b"),
relationship_metadata: RelationshipKind::Colleague,
});
// Collect all references via all_references() (deterministic BTreeMap order).
let all: Vec<&TemplateReference> = map.all_references().collect();
assert_eq!(all.len(), 2);
// First entry's from_template must be the lower ID (BTreeMap key order).
assert!(
all[0].from_template <= all[1].from_template,
"BTreeMap must iterate in ascending key order"
);
}
// ---------------------------------------------------------------------------
// #106: Triangle definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn triangle_def_yaml_parse_with_computed_id() {
// TriangleId is stored in YAML but computed at world-gen time.
// Authors use 0 as placeholder; runtime overwrites with computed value.
let yaml = r#"
triangle_id: 0
roles:
- "ring-smuggler"
- "dock-worker"
- "operations-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
assert_eq!(def.triangle_id, TriangleId(0));
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
assert!(def.relationship_constraints.is_empty());
}
#[test]
fn triangle_def_yaml_parse_with_constraints() {
let yaml = r#"
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: LoyaltyConflict
interest_axes:
- Relationships
- Secret
- Tolerance
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
"#;
let def: TriangleDef =
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
assert_eq!(def.relationship_constraints.len(), 1);
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
}
#[test]
fn triangle_def_all_conflict_types_yaml_roundtrip() {
let conflict_types = [
ConflictType::ResourceCompetition,
ConflictType::LoyaltyConflict,
ConflictType::SecretExposure,
ConflictType::AuthorityChallenge,
ConflictType::LatentTension,
];
for ct in &conflict_types {
let yaml = serde_yaml::to_string(ct).unwrap();
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
}
}
#[test]
fn triangle_def_all_npc_axes_yaml_roundtrip() {
let axes = [
NpcAxis::Want,
NpcAxis::Secret,
NpcAxis::Relationships,
NpcAxis::Tolerance,
NpcAxis::Routine,
NpcAxis::InformationInventory,
NpcAxis::Contentment,
NpcAxis::PersonalityTraits,
NpcAxis::TellSystem,
NpcAxis::SkillSet,
];
for axis in &axes {
let yaml = serde_yaml::to_string(axis).unwrap();
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
}
}
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
#[test]
fn d087_v01_triangle_configurations_expressible() {
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
let t1 = make_triangle(
["kael-davan", "smuggler", "ring-contact"],
ConflictType::ResourceCompetition,
);
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
// T2: Sera-Detective-Commission (SecretExposure, active fork)
let t2 = make_triangle(
["sera-venn", "detective", "commission-inspector"],
ConflictType::SecretExposure,
);
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
let t4 = make_triangle(
["drin", "ring-system", "dock-supervisor"],
ConflictType::ResourceCompetition,
);
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
// T3: passive tension (LatentTension variant per D-087)
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
// T5: background worried partner (LatentTension variant)
let t5 = make_triangle(
["worried-partner", "ring-member", "neighbor"],
ConflictType::LatentTension,
);
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
}
/// D-089: TriangleDef must not contain cross-triangle cascade state.
#[test]
fn d089_no_cross_triangle_cascade_fields() {
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
let yaml = serde_yaml::to_string(&def).expect("serialize");
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
assert!(!yaml.contains("triggers"), "no triggers field should appear");
}
// ---------------------------------------------------------------------------
// #165: ECS integration — spawn two templates with cross-references
// ---------------------------------------------------------------------------
#[test]
fn ecs_two_templates_with_cross_references_and_ownerships() {
use bevy_ecs::world::World;
let seed = 999u64;
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
let mut world = World::new();
world.init_resource::<TemplateReferenceMap>();
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
let npc_logistics = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("logistics-manager"),
})
.id();
let npc_dock = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("dock-worker"),
})
.id();
let npc_bar_regular = world
.spawn(TemplateOwnership {
template_id: tid_bar,
role_id: RoleId::new("bar-regular"),
})
.id();
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
{
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
ref_map.add(TemplateReference {
from_template: tid_terminal,
to_template: tid_bar,
via_role: RoleId::new("dock-worker"),
relationship_metadata: RelationshipKind::Colleague,
});
}
// Verify all TemplateOwnership components are correct.
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
assert_eq!(
own_logistics.template_id, tid_terminal,
"logistics-manager must be owned by terminal"
);
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
assert_eq!(
own_dock.template_id, tid_terminal,
"dock-worker must be owned by terminal"
);
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
// Verify TemplateReferenceMap entries.
let ref_map = world.resource::<TemplateReferenceMap>();
let terminal_refs = ref_map.outgoing(tid_terminal);
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
assert_eq!(terminal_refs[0].to_template, tid_bar);
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
// Bar template has no outgoing references.
assert!(
ref_map.outgoing(tid_bar).is_empty(),
"bar template has no outgoing references"
);
}
#[test]
fn template_ownership_survives_clone_for_save_state() {
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
let tid = TemplateId::from_seed_and_slug(42, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
let saved = ownership.clone();
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
}
+613
View File
@@ -0,0 +1,613 @@
//! Integration tests for the triangle escalation system (#250).
//!
//! Covers the public API from a black-box perspective:
//! - D-087: seed-dependent tension rates produce different 30-min arc timings
//! - D-089: resolution does not cascade (only targeted triangle changes)
//! - D-026: escalation only runs on Active-tier entities
//! - D-031: escalation runs once per game-minute (every 10 ticks)
//!
//! These tests complement the lib unit tests in `src/content/template.rs`
//! with integration-level coverage using the public crate API.
use std::collections::BTreeMap;
use bevy_ecs::{schedule::Schedule, world::World};
use settled_reach_server::{
content::template::{
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId,
TrianglePhase, TriangleState,
},
knowledge::{registry::EntityRegistry, types::StableId, StableEntityId},
npc::ToleranceThreshold,
simulation::{
tier::ActiveSim,
time::SimulationTime,
},
};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Minimal world with all resources required by `tick_triangle_escalation`.
fn make_escalation_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<TriangleCrisisEventQueue>();
world.init_resource::<EntityRegistry>();
world
}
/// Spawn an NPC with a known StableId and ToleranceThreshold.
fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId {
let sid = StableId(stable_id_val);
let entity = world
.spawn((ActiveSim, StableEntityId(sid), ToleranceThreshold { current_stress: 0, threshold }))
.id();
world.resource_mut::<EntityRegistry>().register_existing(entity, sid);
sid
}
/// Spawn a triangle entity with the given state (ActiveSim marker included).
fn spawn_triangle(
world: &mut World,
triangle_id: u64,
tension: u8,
tension_rate: u8,
phase: TrianglePhase,
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
) -> bevy_ecs::entity::Entity {
world
.spawn((
ActiveSim,
TriangleState {
triangle_id: TriangleId(triangle_id),
role_assignments,
tension,
phase,
tension_rate,
template_id: TemplateId(1),
},
))
.id()
}
/// Run the escalation schedule at a specific tick.
fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) {
world.resource_mut::<SimulationTime>().tick = tick;
schedule.run(world);
}
// ---------------------------------------------------------------------------
// #250: Escalation happy path
// ---------------------------------------------------------------------------
/// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute.
/// Tension should only increment on multiples of 10.
#[test]
fn escalation_only_fires_on_game_minute_boundaries() {
let mut world = make_escalation_world();
let entity = spawn_triangle(
&mut world,
1,
0,
5,
TrianglePhase::Simmering,
BTreeMap::new(),
);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Ticks 1-9: not a game-minute, tension must not change.
for tick in 1..10 {
run_at_tick(&mut world, &mut schedule, tick);
}
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
0,
"tension must not change on sub-minute ticks"
);
// Tick 10: first game-minute, tension should increment.
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
5,
"tension must increment at tick 10 (first game-minute)"
);
}
/// Simmering → Active transition at the expected game-minute.
///
/// Known setup:
/// - tension_rate = 5, starting tension = 0
/// - Lowest NPC threshold = 25
/// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering
/// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active
#[test]
fn simmering_transitions_to_active_at_expected_minute() {
let mut world = make_escalation_world();
let npc_a = spawn_npc_with_threshold(&mut world, 1, 40);
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("role-a"), npc_a);
assignments.insert(RoleId::new("role-b"), npc_b);
assignments.insert(RoleId::new("role-c"), npc_c);
let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Run through 50 ticks (5 game-minutes): should remain Simmering.
for tick in 1..=50 {
run_at_tick(&mut world, &mut schedule, tick);
}
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(
state.phase,
TrianglePhase::Simmering,
"after 5 game-minutes (tension=25), must still be Simmering (not > 25)"
);
assert_eq!(state.tension, 25);
// Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active.
for tick in 51..=60 {
run_at_tick(&mut world, &mut schedule, tick);
}
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(
state.phase,
TrianglePhase::Active,
"at tick 60 (tension=30 > threshold=25), must transition to Active"
);
assert_eq!(state.tension, 30);
}
/// D-087: different seeds produce different escalation timings.
/// Verify that two triangles with different tension rates escalate at different times.
#[test]
fn d087_seed_dependent_escalation_timing() {
// Triangle A: slower escalation (rate 2)
// Triangle B: faster escalation (rate 8)
// Both share same NPC threshold (30).
// A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16)
// B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4)
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
// Spawn as separate triangles.
let slow = spawn_triangle(&mut world, 10, 0, 2, TrianglePhase::Simmering, assignments.clone());
let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
// Run 40 game-minutes (400 ticks).
for tick in 1..=400 {
run_at_tick(&mut world, &mut schedule, tick);
}
// Both should be Active by 400 ticks.
assert_eq!(world.get::<TriangleState>(slow).unwrap().phase, TrianglePhase::Active);
assert_eq!(world.get::<TriangleState>(fast).unwrap().phase, TrianglePhase::Active);
// Fast triangle should have activated earlier (higher tension accumulated faster).
let fast_tension = world.get::<TriangleState>(fast).unwrap().tension;
let slow_tension = world.get::<TriangleState>(slow).unwrap().tension;
assert!(
fast_tension > slow_tension,
"fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time"
);
}
/// The trigger NPC in the crisis event is the one with the lowest threshold.
#[test]
fn crisis_event_trigger_npc_is_lowest_threshold() {
let mut world = make_escalation_world();
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r-high"), npc_high);
assignments.insert(RoleId::new("r-low"), npc_low);
// tension_rate = 11 so after 1 game-minute tension = 11 > 10.
spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert_eq!(queue.events.len(), 1, "exactly one crisis event");
assert_eq!(
queue.events[0].trigger_npc, npc_low,
"trigger NPC must be the one with the lowest threshold"
);
assert_eq!(queue.events[0].tick, 10, "crisis tick must match the game-minute");
}
/// No crisis event when tension hasn't exceeded the threshold.
#[test]
fn no_crisis_event_below_threshold() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert!(queue.is_empty(), "no crisis event when tension (5) < threshold (100)");
}
// ---------------------------------------------------------------------------
// #250: Active phase behavior
// ---------------------------------------------------------------------------
/// Active triangle continues incrementing tension (narrative tracking).
/// No additional crisis event emitted.
#[test]
fn active_triangle_continues_incrementing_no_new_event() {
let mut world = make_escalation_world();
spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
run_at_tick(&mut world, &mut schedule, 20);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert!(queue.is_empty(), "no crisis event for already-Active triangle");
}
/// Active triangle tension saturates at u8::MAX (255).
#[test]
fn active_triangle_tension_saturates_at_u8_max() {
let mut world = make_escalation_world();
spawn_triangle(&mut world, 1, 252, 10, TrianglePhase::Active, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
// First call: 252 + 10 = 262, saturates to 255
let entity = world.query::<bevy_ecs::entity::Entity>().iter(&world).next().unwrap();
// Can't query TriangleState after mutable borrow; check via resource
// (we verify by spawning directly and checking post-run)
let _ = entity; // entity used to ensure spawn worked
// Re-run test cleanly
let mut world2 = make_escalation_world();
let e2 = spawn_triangle(&mut world2, 2, 254, 50, TrianglePhase::Active, BTreeMap::new());
let mut sched2 = Schedule::default();
sched2.add_systems(tick_triangle_escalation);
run_at_tick(&mut world2, &mut sched2, 10);
let state = world2.get::<TriangleState>(e2).unwrap();
assert_eq!(state.tension, 255, "tension saturates at u8::MAX");
}
// ---------------------------------------------------------------------------
// #250: D-026 tier boundary
// ---------------------------------------------------------------------------
/// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary).
#[test]
fn d026_non_active_tier_triangle_not_escalated() {
let mut world = make_escalation_world();
world.resource_mut::<SimulationTime>().tick = 10;
// Spawn WITHOUT ActiveSim.
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(1),
role_assignments: BTreeMap::new(),
tension: 10,
phase: TrianglePhase::Simmering,
tension_rate: 5,
template_id: TemplateId(1),
})
.id();
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
10,
"D-026: triangle without ActiveSim must not be escalated"
);
}
/// Dormant triangles are skipped even when in Active tier.
#[test]
fn dormant_triangle_not_escalated() {
let mut world = make_escalation_world();
let entity = spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Dormant, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
0,
"Dormant triangle must not be escalated"
);
}
/// Resolved triangles are skipped (D-089: resolution is permanent).
#[test]
fn resolved_triangle_not_escalated() {
let mut world = make_escalation_world();
let entity = spawn_triangle(&mut world, 1, 50, 5, TrianglePhase::Resolved, BTreeMap::new());
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().tension,
50,
"Resolved triangle must not be escalated (D-089)"
);
}
// ---------------------------------------------------------------------------
// #250: Resolution (D-089)
// ---------------------------------------------------------------------------
/// ResolveTriangleCommand sets the target triangle to Resolved.
#[test]
fn resolve_command_sets_phase_to_resolved() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 50,
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
})
.id();
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
schedule.run(&mut world);
let state = world.get::<TriangleState>(entity).unwrap();
assert_eq!(state.phase, TrianglePhase::Resolved, "resolve command must set phase to Resolved");
assert_eq!(state.tension, 50, "tension must not change on resolve");
}
/// D-089: Resolution does NOT cascade to other triangles.
#[test]
fn d089_resolve_does_not_cascade() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let target = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 50,
phase: TrianglePhase::Active,
tension_rate: 3,
template_id: TemplateId(1),
})
.id();
let bystander_a = world
.spawn(TriangleState {
triangle_id: TriangleId(200),
role_assignments: BTreeMap::new(),
tension: 20,
phase: TrianglePhase::Simmering,
tension_rate: 2,
template_id: TemplateId(1),
})
.id();
let bystander_b = world
.spawn(TriangleState {
triangle_id: TriangleId(300),
role_assignments: BTreeMap::new(),
tension: 80,
phase: TrianglePhase::Active,
tension_rate: 4,
template_id: TemplateId(1),
})
.id();
// Resolve only triangle 100.
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(target).unwrap().phase,
TrianglePhase::Resolved
);
assert_eq!(
world.get::<TriangleState>(bystander_a).unwrap().phase,
TrianglePhase::Simmering,
"D-089: bystander_a must remain Simmering"
);
assert_eq!(
world.get::<TriangleState>(bystander_b).unwrap().phase,
TrianglePhase::Active,
"D-089: bystander_b must remain Active"
);
}
/// Resolving the same triangle twice is idempotent.
#[test]
fn resolve_twice_is_idempotent() {
let mut world = World::new();
world.init_resource::<ResolveTriangleQueue>();
let entity = world
.spawn(TriangleState {
triangle_id: TriangleId(100),
role_assignments: BTreeMap::new(),
tension: 30,
phase: TrianglePhase::Active,
tension_rate: 1,
template_id: TemplateId(1),
})
.id();
let mut schedule = Schedule::default();
schedule.add_systems(apply_resolve_triangle);
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
schedule.run(&mut world);
world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100)));
schedule.run(&mut world);
assert_eq!(
world.get::<TriangleState>(entity).unwrap().phase,
TrianglePhase::Resolved,
"double-resolve must remain Resolved"
);
}
// ---------------------------------------------------------------------------
// #250: Crisis event queue behavior
// ---------------------------------------------------------------------------
/// Crisis events accumulate in the queue until drained.
#[test]
fn crisis_events_accumulate_until_drained() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
// Two triangles that will both escalate.
spawn_triangle(&mut world, 10, 0, 6, TrianglePhase::Simmering, assignments.clone());
spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
let queue = world.resource::<TriangleCrisisEventQueue>();
assert_eq!(
queue.events.len(),
2,
"both triangles should emit crisis events in the same game-minute"
);
}
/// `TriangleCrisisEventQueue::drain` clears the queue.
#[test]
fn crisis_queue_drain_clears_events() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments);
let mut schedule = Schedule::default();
schedule.add_systems(tick_triangle_escalation);
run_at_tick(&mut world, &mut schedule, 10);
// Drain the queue.
let drained = world.resource_mut::<TriangleCrisisEventQueue>().drain();
assert_eq!(drained.len(), 1, "drain should return the 1 event");
assert!(
world.resource::<TriangleCrisisEventQueue>().is_empty(),
"queue must be empty after drain"
);
}
// ---------------------------------------------------------------------------
// #250: YAML triangle def → escalation pipeline
// ---------------------------------------------------------------------------
/// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles
/// (D-087) and those defs produce escalatable TriangleState instances.
#[test]
fn d087_all_v01_conflict_types_produce_escalatable_states() {
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
let defs = [
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
("sera-venn", "detective", "commission-inspector", ConflictType::SecretExposure),
("naia", "kael-davan", "hael", ConflictType::LatentTension),
("drin", "ring-system", "dock-supervisor", ConflictType::ResourceCompetition),
("worried-partner", "ring-member", "neighbor", ConflictType::LatentTension),
];
for (r0, r1, r2, conflict) in &defs {
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
let def = TriangleDef {
triangle_id: tid,
roles: roles.clone(),
conflict_type: *conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
};
assert!(def.validate().is_ok(), "D-087 triangle must be valid: {:?}", def.validate());
// Can construct a TriangleState from the def.
let mut assignments = BTreeMap::new();
for role in &roles {
assignments.insert(role.clone(), StableId(0));
}
let state = TriangleState {
triangle_id: tid,
role_assignments: assignments,
tension: 0,
phase: TrianglePhase::Simmering,
tension_rate: 3,
template_id: TemplateId(1),
};
assert_eq!(
state.phase,
TrianglePhase::Simmering,
"{:?} triangle must start Simmering",
conflict
);
}
}