Compare commits

...
27 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 cafa892b1e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:46:07 +01:00
jpmschweitzerandClaude Opus 4.6 1d2a1fb77d feat(ui): debug visualization overlay (#348)
F3-toggled dev overlay: LOS rays, vision cone arcs, NPC path trails,
knowledge confidence tags, tick timing sparkline. Guarded by
OS.is_debug_build() for export builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:51 +01:00
jpmschweitzerandClaude Opus 4.6 ef135830f2 feat(client): game session management (#258, D-085)
Per-game save directories under user://saves/<timestamp>-<seed>/.
SessionManager autoload handles new_game(), resume_game(), quit flow.
Main menu scene with New Game / Continue / Quit buttons. Game-id
passed to server subprocess via --game-id flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:46 +01:00
jpmschweitzerandClaude Opus 4.6 a7541aa27b test(client): scene testing utilities and GameState tests (#206)
SceneHelper class for gdUnit4: load scenes into test tree with
assert_node_exists, assert_signal_emitted, get_node_at helpers.
14 tests for GameState.apply_snapshot() covering v2+ fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:39 +01:00
jpmschweitzerandClaude Opus 4.6 372949508e feat(client): gdUnit4 CI runner script (#205)
Headless test runner that delegates to GdUnitTestCIRunner for CI
integration. Exit code 0 = all pass, non-zero = failures per D-030.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:45:34 +01:00
jpmschweitzerandClaude Opus 4.6 0dd33690f7 docs(sprints): add Sprint 19: Persist briefings
Server (7), client (5), CI (4) briefings plus joint integration plan.
Save/load with D-085 per-game dirs, tier eviction/scope, test infra.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:29:41 +01:00
jpmschweitzerandClaude Opus 4.6 75e821ccd7 chore(config): gitignore agent-memory scratch files
Session-specific working notes that are context-brittle and would
create noise across branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:15:42 +01:00
jpmschweitzerandClaude Opus 4.6 511fdfef46 docs(wireframes): update save/load wireframe for D-085 per-game grouping
LOAD tab now shows games grouped by directory with expand/collapse.
QUICKSAVE slot distinct from AUTOSAVE. F5/F6 keybind hints in footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:10:01 +01:00
jpmschweitzerandClaude Opus 4.6 0cf4dcc3f9 feat(db): decision ID claim system — prevent cross-worktree collisions
- `db/connectors/decision next [D|Q|R]` — show next available ID
- `db/connectors/decision claim <prefix> <domain> [title]` — reserve ID in DB
- `db/connectors/decision check-dupes` — detect duplicate IDs in markdown
- `tooling/check-decision-ids` — pre-commit hook for dupe detection
- D-035 added as known exception (139 files, too embedded to renumber)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:51:46 +01:00
jpmschweitzerandClaude Opus 4.6 7ca1b78153 docs(decisions): renumber insert icon system D-084 → D-086
Resolves ID collision: D-084 was used independently by both the copy
branch (line ID scheme, content.md) and the visual branch (insert icon
system, perception.md). Content's D-084 keeps the number; visual's is
renumbered to D-086.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:45:13 +01:00
jpmschweitzerandClaude Opus 4.6 ff7f99da7a chore(db): backup database after Sprint 18 close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:44:23 +01:00
jpmschweitzer 079f98dc2e Merge remote-tracking branch 'origin/visual'
# Conflicts:
#	decisions/README.md
2026-02-25 10:44:09 +01:00
jpmschweitzerandClaude Opus 4.6 262cac5849 docs(decisions): add D-085 per-game save dirs, Q-029 save file format
D-085: per-game save directory structure with F5/F6 quicksave/quickload.
Q-029: open question tracking long-term save file format design.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:43:21 +01:00
jpmschweitzer f5c5ac635c Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/bridge/types.rs
2026-02-25 10:09:27 +01:00
jpmschweitzerandClaude Opus 4.6 768a431c38 fix(simulation): PR #66 review — dedup vision, spawn components, doc fixes
- vision.rs: deduplicate own-tile entity iteration, add same-tile test
- spawn.rs: add NpcVisionState, NpcMemory, PlayerAwareness to content-
  spawned NPCs (matching generate_npc)
- pressure.rs: update who_knows_full_scan doc to reflect actual call
  frequency, document O(N) acceptability at call site
- types.rs: fix stale protocol version comment (13→14)
- generate.rs: format!() → .to_string() (clippy)
- vision.rs: hardcoded 10 → TICKS_PER_GAME_MINUTE
- relationships.rs: fix comment "15%" → "20%" to match code
- pressure.rs: document total() floor-truncation
- save_state.rs: document NpcMemory exclusion as intentional

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:04:18 +01:00
jpmschweitzer bdd24ef2d5 Merge remote-tracking branch 'origin/copy'
# Conflicts:
#	CHANGELOG.md
2026-02-25 09:59:20 +01:00
jpmschweitzerandClaude Opus 4.6 abd1657d94 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:51:09 +01:00
jpmschweitzerandClaude Opus 4.6 31ae462cb2 test(simulation): update protocol fixtures and golden for Sprint 18
Regenerated MsgPack fixtures for protocol v14 (examine_result and
character_pressure fields). Updated golden test, serialization
assertions, and cross-language fixture generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:50:48 +01:00
jpmschweitzerandClaude Opus 4.6 276538cde1 feat(simulation): Sprint 18 — 9 server systems
Background tier state machines (#95), NPC vision (#115), player-
awareness behavior (#244), skill system & combat flag (#91), social
propagation (#249), examine mechanic (#242), character pressure
framework (#248), save state data model (#256), tell state wiring
(#337). Protocol version bumped 13→14 for examine_result and
character_pressure snapshot fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:50:37 +01:00
jpmschweitzerandClaude Opus 4.6 cc898d60c7 fix(content): PR #64 round 2 — NPC ID, outcome gating, fact stubs
Fix npc:nils → npc:nils-davan canonical ID mismatch. Replace
auto-flag gates on ring_completes/ring_splinters with player-
action-gated fact conditions (unreachable outcomes). Add
ring_stalemate terminal exit for non-terminal ring_splinters.
Create ring.yaml with 10 fact stubs for content/global/knowledge/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:49:09 +01:00
jpmschweitzerandClaude Opus 4.6 c55474f46d fix(content): PR #64 review — schema compliance and orphaned flags
Fix events_fired referencing flag names instead of event IDs (two
outcomes unreachable). Wire 4 orphaned flags into narratively
coupled outcome conditions. Fix typo and stale question count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:42:43 +01:00
jpmschweitzerandClaude Opus 4.6 2493dcb5f0 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:57 +01:00
jpmschweitzerandClaude Opus 4.6 16a79c928c feat(content): Tier 1 drama module schema and smuggling ring stub (#158)
JSON Schema for drama modules covering entry conditions, NPC
requirements, event sequences, and outcomes. Includes v0.1
vertical slice stub module and authoring guide with review
feedback from Mellanie (pattern/motivation reference, terminal
outcome semantics, fact ID conventions, trigger type bridge).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:39 +01:00
jpmschweitzerandClaude Opus 4.6 9cec3df68c feat(content): D-084 dual-namespace line ID scheme — resolve Q-028 (#544)
Role namespace (shared pool) stays unchanged; instance namespace
(role-slug + seeded counter) added as opt-in for per-instance
authored content on auto-generated NPCs. No schema migration needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:27:33 +01:00
jpmschweitzer 4c812058f2 chore(meta): resolve CHANGELOG conflict — merge main into visual 2026-02-25 02:04:45 +01:00
jpmschweitzerandClaude Opus 4.6 92f262bebf chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:38:14 +01:00
jpmschweitzerandClaude Opus 4.6 2ce71dedf6 docs(decisions): register D-084 insert icon system — custom SVG, no icon font
Insert overlay uses custom SVG icons instead of an icon font. No
available font matches the neural lattice visual language. Custom
SVGs respond to lattice_profile weight parameter and maintain visual
coherence with existing geometric primitives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:37:58 +01:00
72 changed files with 8918 additions and 120 deletions
+1
View File
@@ -21,6 +21,7 @@ run_check() {
# --- Checks ---
run_check "tooling/check-fact-ids" "fact_id validation"
run_check "tooling/check-decision-ids" "decision ID duplication"
if [ "$ERRORS" -gt 0 ]; then
echo ""
+1
View File
@@ -43,3 +43,4 @@ Thumbs.db
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
.claude/plans/
.claude/projects/
.claude/agent-memory/
+21
View File
@@ -7,11 +7,31 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
@@ -26,6 +46,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
+2
View File
@@ -56,12 +56,14 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
+2
View File
@@ -170,6 +170,8 @@ dialogue:
menu:
pause_title: "Paused"
resume: "Resume"
new_game: "New Game"
continue: "Continue"
settings: "Settings"
save_game: "Save"
load_game: "Load"
+2 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="The Settled Reach"
run/main_scene="res://scenes/main.tscn"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
SessionManager="*res://scripts/autoloads/session_manager.gd"
[audio]
+66
View File
@@ -0,0 +1,66 @@
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
; Main menu — New Game / Continue / Quit.
; #258: D-085 per-game save directory created on New Game.
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_mainmenu")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.05, 0.08, 1.0)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -120.0
offset_top = -80.0
offset_right = 120.0
offset_bottom = 100.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 16
alignment = 1
[node name="TitleLabel" type="Label" parent="VBox"]
layout_mode = 2
text = "THE SETTLED REACH"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 36
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
[node name="Spacer" type="Control" parent="VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 24)
[node name="NewGameBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "NEW GAME"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="ContinueBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "CONTINUE"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
[node name="QuitBtn" type="Button" parent="VBox"]
layout_mode = 2
text = "QUIT"
theme_override_font_sizes/font_size = 15
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
+5
View File
@@ -4,6 +4,11 @@ extends Node
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
# Tiles use format: [{x, y, z, type}].
var current_snapshot: Dictionary = {}
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
# Set by SessionManager.new_game() or SessionManager.resume_game().
# Empty string when no session is active (main menu state).
var current_game_id: String = ""
var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
+117
View File
@@ -0,0 +1,117 @@
extends Node
## D-085 (#258): Game session lifecycle manager.
## Creates per-game save directories on New Game, resumes existing sessions,
## and handles quit-to-menu flow with save confirmation.
##
## All save dirs live under user://saves/<game-id>/ where game-id is
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
const SAVES_DIR := "user://saves/"
const GAME_SCENE := "res://scenes/main.tscn"
const MENU_SCENE := "res://scenes/main_menu.tscn"
var _quit_dialog: ConfirmationDialog = null
## Generate a new game-id, create its save directory, and activate the session.
## Returns the new game-id string.
func new_game() -> String:
var now := Time.get_datetime_dict_from_system()
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
now.year, now.month, now.day,
now.hour, now.minute, now.second,
]
var rng := RandomNumberGenerator.new()
rng.randomize()
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
var game_id := "%s-%s" % [timestamp, hex_seed]
var save_path := SAVES_DIR + game_id + "/"
var err := DirAccess.make_dir_recursive_absolute(save_path)
if err != OK:
push_error("SessionManager: failed to create save dir %s: %s" % [
save_path, error_string(err)])
GameState.current_game_id = game_id
return game_id
## Resume an existing game session by setting the active game-id.
func resume_game(game_id: String) -> void:
GameState.current_game_id = game_id
## List all game directories under user://saves/ sorted by last-modified (most recent first).
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
func list_game_dirs() -> Array:
var dir := DirAccess.open(SAVES_DIR)
if dir == null:
return []
var results: Array = []
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if dir.current_is_dir() and not entry.begins_with("."):
var dir_path := SAVES_DIR + entry + "/"
var newest_save := _find_newest_save(dir_path)
var mtime: int = 0
if newest_save != "":
mtime = FileAccess.get_modified_time(dir_path + newest_save)
results.append({
"game_id": entry,
"modified_time": mtime,
"newest_save": newest_save,
})
entry = dir.get_next()
dir.list_dir_end()
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.modified_time > b.modified_time)
return results
## Show "Save before quitting?" confirmation dialog, then return to main menu.
## node: the calling scene node (used as dialog parent).
## #554: The actual F5 save will be wired here once server supports SaveCommand.
func quit_to_menu(node: Node) -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
return # Dialog already open
_quit_dialog = ConfirmationDialog.new()
_quit_dialog.title = ""
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
node.add_child(_quit_dialog)
_quit_dialog.confirmed.connect(_do_quit_to_menu)
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
_quit_dialog.popup_centered()
func _do_quit_to_menu() -> void:
_cleanup_quit_dialog()
# #554: F5 quicksave will be triggered here before scene change when server
# supports SaveCommand. For now: navigate to menu without saving.
GameState.current_game_id = ""
get_tree().change_scene_to_file(MENU_SCENE)
func _cleanup_quit_dialog() -> void:
if _quit_dialog != null and is_instance_valid(_quit_dialog):
_quit_dialog.queue_free()
_quit_dialog = null
func _find_newest_save(dir_path: String) -> String:
var dir := DirAccess.open(dir_path)
if dir == null:
return ""
var best_name := ""
var best_time: int = 0
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if not dir.current_is_dir() and entry.ends_with(".sav"):
var mtime := FileAccess.get_modified_time(dir_path + entry)
if mtime > best_time:
best_time = mtime
best_name = entry
entry = dir.get_next()
dir.list_dir_end()
return best_name
+7 -2
View File
@@ -66,8 +66,13 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid := _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
+353 -17
View File
@@ -1,5 +1,15 @@
extends Control
# #511: F3 debug overlay — real-time game state display for dev use.
## #348: F3 debug overlay — real-time visualization of game state for dev use.
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
##
## Panels:
## 1. Stats text (top-left): tick, pos, fps, etc.
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const HEADER_COLOR := Color("#e8c547")
const LABEL_COLOR := Color("#8890a0")
@@ -8,31 +18,141 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
const FONT_SIZE := 12
const LINE_HEIGHT := 16
const PADDING := Vector2(10, 8)
const COL_GAP := 16 # gap between left and right columns
const COL_GAP := 16
# World overlay colors
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
const TAG_TEXT_COLOR := Color("#c8d0e0")
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
const GRAPH_LINE_COLOR := Color("#6bc9a6")
const GRAPH_WARN_COLOR := Color("#e8c547")
# Vision cone geometry (radians)
# Forward: ±60° around facing direction (120° total)
# Peripheral: ±60° to ±120° on each side (60° band each side)
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
const CONE_ARC_STEPS: int = 20
# NPC path history
const NPC_HISTORY_LEN: int = 12
const NPC_DOT_RADIUS: float = 3.5
const PLAYER_DOT_RADIUS: float = 5.0
# Tick timing graph
const GRAPH_W: float = 160.0
const GRAPH_H: float = 48.0
const GRAPH_MARGIN: float = 10.0
const TICK_HISTORY_LEN: int = 30
const TICK_WARN_MS: float = 120.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _cached_font: Font = null
var _dev_mode: bool = false
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
var _npc_paths: Dictionary = {}
var _last_tick_processed: int = -1
# Tick timing ring
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
var _tick_deltas: Array = [] # ms between consecutive snapshots
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_dev_mode = OS.is_debug_build()
visible = false
_cached_font = ThemeDB.fallback_font
func _unhandled_input(event: InputEvent) -> void:
if not _dev_mode:
return
if event.is_action_pressed("debug_overlay"):
visible = not visible
if visible:
queue_redraw()
func update_from_state() -> void:
if not visible:
if not _dev_mode or not visible:
return
# Record tick arrival time for timing graph
var now_ms := Time.get_ticks_msec()
if _last_tick_processed != GameState.current_tick:
_last_tick_processed = GameState.current_tick
if _tick_times.size() > 0:
_tick_deltas.append(float(now_ms - _tick_times.back()))
if _tick_deltas.size() > TICK_HISTORY_LEN:
_tick_deltas.pop_front()
_tick_times.append(now_ms)
if _tick_times.size() > TICK_HISTORY_LEN + 1:
_tick_times.pop_front()
_update_npc_paths()
queue_redraw()
func _update_npc_paths() -> void:
var seen_ids: Dictionary = {}
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
var kind_variant: String = entity.get("kind", {}).get("variant", "")
if kind_variant != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if eid < 0:
continue
seen_ids[eid] = true
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
if not _npc_paths.has(eid):
_npc_paths[eid] = []
var path: Array = _npc_paths[eid]
if path.size() == 0 or path.back() != pos:
path.append(pos)
if path.size() > NPC_HISTORY_LEN:
path.pop_front()
# Prune entities no longer visible
for eid in _npc_paths.keys():
if not seen_ids.has(eid):
_npc_paths.erase(eid)
# ---------------------------------------------------------------------------
# Draw dispatch
# ---------------------------------------------------------------------------
func _draw() -> void:
if not visible:
return
_draw_stats_panel()
_draw_world_overlays()
_draw_tick_graph()
# ---------------------------------------------------------------------------
# Panel 1: Stats text (top-left)
# ---------------------------------------------------------------------------
func _draw_stats_panel() -> void:
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
# Build lines as [label, value, label, value] pairs (two columns)
var left_lines: Array = []
var right_lines: Array = []
@@ -71,7 +191,10 @@ func _draw() -> void:
left_lines.append(["mode", mode_str])
right_lines.append(["gauntlet", gauntlet_str])
# Measure column widths
var gid := GameState.current_game_id
left_lines.append(["game_id", gid if gid != "" else "-"])
right_lines.append(["npc_paths", str(_npc_paths.size())])
var left_label_w: float = 0.0
var left_value_w: float = 0.0
var right_label_w: float = 0.0
@@ -89,27 +212,240 @@ func _draw() -> void:
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size())
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
y += LINE_HEIGHT
# Data lines (two columns)
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count):
if i < left_lines.size():
var lbl: String = left_lines[i][0] + ": "
var val: String = left_lines[i][1]
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
var lbl: String = right_lines[i][0] + ": "
var val: String = right_lines[i][1]
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
# ---------------------------------------------------------------------------
# Panel 2: World overlays
# ---------------------------------------------------------------------------
func _draw_world_overlays() -> void:
var vp := get_viewport()
if vp == null:
return
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
# Using this transform converts world coords → screen pixel coords for the overlays.
var canvas_xf := vp.get_canvas_transform()
var player_screen := _w2s(GameState.player_position, canvas_xf)
_draw_vision_cone(player_screen, canvas_xf)
_draw_los_rays(player_screen, canvas_xf)
_draw_npc_paths(canvas_xf)
_draw_info_tags(canvas_xf)
# Convert world tile position → screen pixel position
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
return canvas_xf * (world_pos * Constants.TILE_SIZE)
# Vision cone: filled forward sector + peripheral bands.
# Uses player facing direction and visibility sector distance.
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
var facing_angle := _facing_to_angle(GameState.player_facing)
# Estimate visible radius from furthest visibility sector tile
var max_d: float = 4.0
for vpos in GameState.visibility_sectors.keys():
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
if d > max_d:
max_d = d
var scale_x := canvas_xf.x.length()
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
var forward_from := facing_angle - CONE_FORWARD_HALF
var forward_to := facing_angle + CONE_FORWARD_HALF
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
var perip_l_to := facing_angle - CONE_FORWARD_HALF
var perip_r_from := facing_angle + CONE_FORWARD_HALF
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
# Forward arc boundary ring
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
# Player dot
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
# Build a filled polygon fan from center through an arc
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
var pts := PackedVector2Array()
pts.append(center)
for i in range(CONE_ARC_STEPS + 1):
var t := float(i) / float(CONE_ARC_STEPS)
var a := angle_from + t * (angle_to - angle_from)
pts.append(center + Vector2(cos(a), sin(a)) * radius)
return pts
# Dashed LOS lines from player to each visible non-player entity
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") == "Player":
continue
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
var entity_screen := _w2s(entity_world, canvas_xf)
var rel: String = entity.get("relationship", "Unknown")
var color := Constants.color_for_relationship(rel)
color.a = 0.45
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
# Fading NPC movement path trails from position history
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
for eid in _npc_paths.keys():
var path: Array = _npc_paths[eid]
if path.size() < 2:
continue
for i in range(1, path.size()):
var a_screen := _w2s(path[i - 1], canvas_xf)
var b_screen := _w2s(path[i], canvas_xf)
var alpha := float(i) / float(path.size())
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
# Information state tags above visible NPCs from player_knowledge
func _draw_info_tags(canvas_xf: Transform2D) -> void:
if GameState.player_knowledge == null:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var knowledge: Dictionary = GameState.player_knowledge
# Build entity_id → knowledge entry lookup
var kg_by_id: Dictionary = {}
for entry in knowledge.get("entities", []):
if entry is Dictionary and entry.has("entity_id"):
kg_by_id[entry.entity_id] = entry
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if entity.get("kind", {}).get("variant", "") != "Npc":
continue
var eid: int = entity.get("entity_id", -1)
if not kg_by_id.has(eid):
continue
var kg_entry: Dictionary = kg_by_id[eid]
var confidence: String = kg_entry.get("confidence", "Unknown")
var name_str: String = kg_entry.get("name", "?")
var label := "%s [%s]" % [name_str, confidence]
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
var tag_baseline := entity_screen.y - 18.0
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
var tag_rect := Rect2(
entity_screen.x - text_w / 2.0 - 3.0,
tag_baseline - FONT_SIZE + 2.0,
text_w + 6.0,
FONT_SIZE)
draw_rect(tag_rect, TAG_BG_COLOR)
draw_string(font,
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
# ---------------------------------------------------------------------------
# Panel 3: Tick timing sparkline (bottom-left)
# ---------------------------------------------------------------------------
func _draw_tick_graph() -> void:
if _tick_deltas.size() < 2:
return
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
var vp_size := get_viewport_rect().size
var box_x := GRAPH_MARGIN
var label_h := LINE_HEIGHT
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
draw_string(font,
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
"tick ms (n=%d)" % _tick_deltas.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
var chart_top := box_y + label_h
var chart_left := box_x + 4.0
var chart_w := GRAPH_W - 8.0
var chart_h := GRAPH_H - 4.0
# Max value for scale
var max_ms: float = TICK_WARN_MS
for d in _tick_deltas:
if float(d) > max_ms:
max_ms = float(d)
max_ms *= 1.1
# Warn threshold dashed line
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
draw_dashed_line(
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
1.0, 4.0)
# Sparkline
var n := _tick_deltas.size()
var prev_pt := Vector2.ZERO
for i in range(n):
var x := chart_left + chart_w * (float(i) / float(n - 1))
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
var pt := Vector2(x, y)
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
if i > 0:
draw_line(prev_pt, pt, color, 1.5)
draw_circle(pt, 2.0, color)
prev_pt = pt
# Average label
var avg_ms := 0.0
for d in _tick_deltas:
avg_ms += float(d)
avg_ms /= float(_tick_deltas.size())
draw_string(font,
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
"avg %.0fms" % avg_ms,
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
static func _facing_to_angle(facing: String) -> float:
match facing:
"North": return -PI / 2.0
"Northeast": return -PI / 4.0
"East": return 0.0
"Southeast": return PI / 4.0
"South": return PI / 2.0
"Southwest": return PI * 3.0 / 4.0
"West": return PI
"Northwest": return -PI * 3.0 / 4.0
_: return -PI / 2.0
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.
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env -S godot -s
## gdUnit4 CI runner for the Settled Reach client.
##
## Run all client tests headlessly:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/
##
## Run a specific test file:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
##
## Exit code: 0 = all pass, non-zero = failures.
##
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
## Test output format: JSON summary per D-030 sub-decision #6.
extends SceneTree
var _cli_runner: GdUnitTestCIRunner
func _initialize() -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
_cli_runner = GdUnitTestCIRunner.new()
root.add_child(_cli_runner)
func _finalize() -> void:
queue_delete(_cli_runner)
+317
View File
@@ -0,0 +1,317 @@
## Sprint 19 — Debug visualization overlay (#348)
## F3 toggle, world overlays, tick timing graph.
## Extends the existing debug_overlay.gd stub.
class_name TestDebugOverlaySprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
func _make_overlay() -> Control:
## Instantiate a standalone DebugOverlay control for unit testing.
## Does not require the full main.tscn scene tree.
var script := load(DEBUG_SCRIPT_PATH)
if script == null:
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
return null
var node := Control.new()
node.set_script(script)
add_child(node)
return node
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.current_tick = 1
GameState.player_knowledge = null
func after_test() -> void:
GameState.visible_entities = []
GameState.player_knowledge = null
# ---------------------------------------------------------------------------
# Script existence
# ---------------------------------------------------------------------------
func test_debug_overlay_script_exists() -> void:
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
).is_true()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
func test_debug_overlay_instantiates_without_crash() -> void:
var ol := _make_overlay()
if ol == null: return
assert_that(ol).is_not_null()
ol.queue_free()
func test_debug_overlay_starts_hidden() -> void:
## Overlay starts hidden — only appears when F3 pressed.
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.visible).override_failure_message(
"DebugOverlay must start hidden (visible=false)"
).is_false()
ol.queue_free()
# ---------------------------------------------------------------------------
# Dev-only guard
# ---------------------------------------------------------------------------
func test_update_from_state_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has_method("update_from_state")).override_failure_message(
"DebugOverlay must have update_from_state() method"
).is_true()
ol.queue_free()
func test_update_from_state_does_not_crash_when_hidden() -> void:
## update_from_state() called while hidden must not crash.
var ol := _make_overlay()
if ol == null: return
ol.visible = false
ol.update_from_state() # Should be a no-op, no crash
ol.queue_free()
func test_update_from_state_does_not_crash_when_visible() -> void:
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate a minimal snapshot tick
GameState.current_tick = 42
ol.update_from_state()
ol.queue_free()
# ---------------------------------------------------------------------------
# NPC path tracking
# ---------------------------------------------------------------------------
func test_npc_paths_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_npc_paths")).override_failure_message(
"DebugOverlay must have _npc_paths field for NPC movement history"
).is_true()
ol.queue_free()
func test_npc_paths_updated_on_state_update() -> void:
## After update_from_state with an NPC entity, _npc_paths should have an entry.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 2,
"x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 100
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"_npc_paths must record NPC positions from visible_entities"
).is_greater(0)
ol.queue_free()
func test_npc_paths_not_populated_for_player_entity() -> void:
## Player entities must not appear in NPC path history.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 1,
"x": 10.0, "y": 10.0, "z": 0,
"kind": {"variant": "Player", "data": null},
}]
GameState.current_tick = 101
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"Player entity must not appear in _npc_paths"
).is_equal(0)
ol.queue_free()
func test_npc_paths_max_length_respected() -> void:
## Path history must not grow beyond NPC_HISTORY_LEN entries.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate NPC moving each tick — inject 20 ticks of movement
for i in range(20):
GameState.visible_entities = [{
"entity_id": 5,
"x": float(12 + i), "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 200 + i
ol.update_from_state()
var path: Array = ol._npc_paths.get(5, [])
assert_int(path.size()).override_failure_message(
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
).is_less_equal(ol.NPC_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Tick timing ring
# ---------------------------------------------------------------------------
func test_tick_deltas_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_tick_deltas")).override_failure_message(
"DebugOverlay must have _tick_deltas field for timing sparkline"
).is_true()
ol.queue_free()
func test_tick_deltas_accumulate_over_state_updates() -> void:
## Each new tick snapshot should add a delta to _tick_deltas.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(5):
GameState.current_tick = 300 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must accumulate entries from successive ticks"
).is_greater(0)
ol.queue_free()
func test_tick_deltas_max_length_respected() -> void:
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(50):
GameState.current_tick = 400 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
).is_less_equal(ol.TICK_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Constants defined
# ---------------------------------------------------------------------------
func test_npc_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have NPC_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have TICK_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_warn_ms_constant_defined() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
).is_true()
ol.queue_free()
# ---------------------------------------------------------------------------
# Facing angle helper
# ---------------------------------------------------------------------------
func test_facing_to_angle_north() -> void:
## North = -PI/2 in Godot 2D (up on screen)
var angle := _fetch_facing_angle("North")
assert_float(angle).override_failure_message(
"_facing_to_angle('North') must return -PI/2"
).is_equal_approx(-PI / 2.0, 0.001)
func test_facing_to_angle_east() -> void:
var angle := _fetch_facing_angle("East")
assert_float(angle).is_equal_approx(0.0, 0.001)
func test_facing_to_angle_south() -> void:
var angle := _fetch_facing_angle("South")
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
func test_facing_to_angle_west() -> void:
var angle := _fetch_facing_angle("West")
assert_float(angle).is_equal_approx(PI, 0.001)
func _fetch_facing_angle(facing: String) -> float:
## Helper: load script and call static method.
var script = load(DEBUG_SCRIPT_PATH)
if script == null:
return 0.0
# In GDScript 4, static methods can be called via an instance
var tmp := Control.new()
tmp.set_script(script)
add_child(tmp)
var result := tmp._facing_to_angle(facing)
tmp.queue_free()
return result
# ---------------------------------------------------------------------------
# In-scene placement: DebugOverlay on UILayer
# ---------------------------------------------------------------------------
func test_debug_overlay_in_main_scene_ui_layer() -> void:
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var ui_layer := scene.get_node_or_null("UILayer")
assert_that(ui_layer != null).override_failure_message(
"UILayer must exist in main.tscn"
).is_true()
if ui_layer == null: return
var overlay := ui_layer.get_node_or_null("DebugOverlay")
assert_that(overlay != null).override_failure_message(
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
).is_true()
+156
View File
@@ -0,0 +1,156 @@
## GameState.apply_snapshot() tests — v2+ field coverage.
##
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
## player_position). This file covers v2+ fields and derived state.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameState
extends GdUnitTestSuite
func before_each() -> void:
# Reset fields touched by these tests to known defaults.
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.player_facing = "North"
GameState.game_time = {}
GameState.nearby_interactions = []
GameState.current_monologue = null
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState.insert_active = true
# -- v2: game_time (D-031) -------------------------------------------------
func test_apply_snapshot_sets_game_time() -> void:
var snapshot := {
"tick": 10,
"entities": [],
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.game_time).is_not_null()
assert_that(GameState.game_time.get("day")).is_equal(3)
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
GameState.game_time = {"day": 2, "time_of_day": 360}
GameState.apply_snapshot({"tick": 5, "entities": []})
# No "game_time" key → field unchanged
assert_that(GameState.game_time.get("day")).is_equal(2)
# -- v2: player_facing (D-015) --------------------------------------------
func test_apply_snapshot_sets_player_facing() -> void:
var snapshot := {
"tick": 1,
"entities": [],
"player_facing": "Southeast",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.player_facing).is_equal("Southeast")
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
GameState.player_facing = "West"
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.player_facing).is_equal("West")
# -- v4: nearby_interactions (#404/#405) ----------------------------------
func test_apply_snapshot_sets_nearby_interactions() -> void:
var interactions := [
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
GameState.nearby_interactions = [{"entity_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.nearby_interactions.size()).is_equal(0)
# -- v5: current_monologue (#414) -----------------------------------------
func test_apply_snapshot_sets_monologue() -> void:
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
func test_apply_snapshot_monologue_absent_clears_field() -> void:
GameState.current_monologue = {"id": "old", "text": "Old line."}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.current_monologue).is_null()
# -- v6: player_stance (#449, D-053) --------------------------------------
func test_apply_snapshot_sets_player_stance() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
assert_that(GameState.player_stance).is_equal("Crouch")
# -- v6: player_inventory (#449, D-065) -----------------------------------
func test_apply_snapshot_sets_player_inventory() -> void:
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
assert_that(GameState.player_inventory.size()).is_equal(1)
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
func test_apply_snapshot_inventory_absent_clears_list() -> void:
GameState.player_inventory = [{"item_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.player_inventory.size()).is_equal(0)
# -- Stationary tick counter (D-071) -------------------------------------
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
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) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_resets_on_player_movement() -> void:
var s1 := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
var s2 := {
"tick": 2,
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(s1)
GameState.apply_snapshot(s1) # stationary
assert_int(GameState.stationary_ticks).is_greater(0)
GameState.apply_snapshot(s2) # moved → reset
assert_int(GameState.stationary_ticks).is_equal(0)
# -- insert_active (OQ-07, #522) -----------------------------------------
func test_apply_snapshot_insert_active_false() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
assert_bool(GameState.insert_active).is_false()
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
GameState.insert_active = false
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_bool(GameState.insert_active).is_true()
@@ -0,0 +1,189 @@
## Sprint 19 — Game session management (#258, D-085)
## Per-game save directories: created on New Game, resumed via game-id.
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
class_name TestSessionManagerSprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_game_id = ""
func after_test() -> void:
GameState.current_game_id = ""
# ---------------------------------------------------------------------------
# GameState.current_game_id field
# ---------------------------------------------------------------------------
func test_current_game_id_field_exists() -> void:
## D-085: GameState must have current_game_id field.
assert_bool(GameState.has("current_game_id")).override_failure_message(
"GameState must have 'current_game_id' field (D-085 #258)"
).is_true()
func test_current_game_id_default_is_empty_string() -> void:
## Before any session starts, current_game_id is empty.
GameState.current_game_id = ""
assert_str(GameState.current_game_id).override_failure_message(
"GameState.current_game_id default must be empty string"
).is_empty()
# ---------------------------------------------------------------------------
# SessionManager autoload exists
# ---------------------------------------------------------------------------
func test_session_manager_autoload_exists() -> void:
## SessionManager must be registered as an autoload.
var sm := Engine.get_singleton("SessionManager")
assert_that(sm != null).override_failure_message(
"SessionManager must be registered as autoload in project.godot (#258)"
).is_true()
# ---------------------------------------------------------------------------
# new_game() — game-id format and GameState update
# ---------------------------------------------------------------------------
func test_new_game_returns_non_empty_string() -> void:
var game_id := SessionManager.new_game()
assert_str(game_id).override_failure_message(
"SessionManager.new_game() must return a non-empty game-id string"
).is_not_empty()
func test_new_game_sets_current_game_id_on_gamestate() -> void:
var game_id := SessionManager.new_game()
assert_str(GameState.current_game_id).override_failure_message(
"new_game() must set GameState.current_game_id"
).is_equal(game_id)
func test_new_game_id_format_has_two_dashes() -> void:
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts.size()).override_failure_message(
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
).is_equal(3)
func test_new_game_id_first_part_is_8_digits() -> void:
## First part is YYYYMMDD — 8 decimal digits.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[0].length()).override_failure_message(
"game-id first part (date) must be 8 characters (YYYYMMDD)"
).is_equal(8)
func test_new_game_id_second_part_is_6_digits() -> void:
## Second part is HHMMSS — 6 decimal digits.
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[1].length()).override_failure_message(
"game-id second part (time) must be 6 characters (HHMMSS)"
).is_equal(6)
func test_new_game_id_third_part_is_6_hex_chars() -> void:
## Third part is 6 hex characters (RNG seed).
var game_id := SessionManager.new_game()
var parts := game_id.split("-")
assert_int(parts[2].length()).override_failure_message(
"game-id third part (hex seed) must be 6 characters"
).is_equal(6)
func test_new_game_ids_are_unique() -> void:
## Two rapid new_game() calls should produce different IDs
## (different RNG seeds; same-second timestamps are valid but seeds differ).
var id1 := SessionManager.new_game()
var id2 := SessionManager.new_game()
# Check that hex seeds differ (they almost certainly will)
var seed1 := id1.split("-")[2]
var seed2 := id2.split("-")[2]
assert_str(seed1).override_failure_message(
"Successive new_game() calls should have different RNG seeds"
).is_not_equal(seed2)
# ---------------------------------------------------------------------------
# resume_game() — sets GameState.current_game_id
# ---------------------------------------------------------------------------
func test_resume_game_sets_current_game_id() -> void:
var test_id := "20260225-143022-a7b3f1"
SessionManager.resume_game(test_id)
assert_str(GameState.current_game_id).override_failure_message(
"resume_game() must set GameState.current_game_id to the given id"
).is_equal(test_id)
func test_resume_game_overwrites_previous_game_id() -> void:
SessionManager.resume_game("20260225-100000-aabbcc")
SessionManager.resume_game("20260225-120000-112233")
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
# ---------------------------------------------------------------------------
# Main menu scene
# ---------------------------------------------------------------------------
func test_main_menu_scene_exists() -> void:
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
).is_true()
func test_main_menu_instantiates_without_crash() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
assert_that(scene).is_not_null()
func test_main_menu_has_new_game_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/NewGameBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/NewGameBtn (#258)"
).is_true()
func test_main_menu_has_continue_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/ContinueBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/ContinueBtn (#258)"
).is_true()
# ---------------------------------------------------------------------------
# Project main scene changed to main_menu.tscn
# ---------------------------------------------------------------------------
func test_project_main_scene_is_main_menu() -> void:
## D-085: project boots to main menu, not directly to game scene.
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
assert_str(scene_path).override_failure_message(
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
).is_equal("res://scenes/main_menu.tscn")
+99
View File
@@ -0,0 +1,99 @@
## Scene testing utilities for gdUnit4 tests.
##
## Loads a scene, instantiates it into the test suite's node tree,
## and provides helpers for node existence, signal, and node-path queries.
##
## Usage (from a GdUnitTestSuite subclass):
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
## helper.assert_node_exists("World")
## var world := helper.get_node_at("World")
## helper.monitor_signal(world, "ready")
## # ... trigger something ...
## helper.assert_signal_emitted(world, "ready")
##
## Design constraints (D-030): server-free, no running autoload dependencies.
class_name SceneHelper
extends RefCounted
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
var _scene: Node
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
var _signal_hits: Dictionary = {}
## Load, instantiate, and attach a scene to the test suite's node tree.
## The scene node is registered for auto-free by gdUnit4.
## Returns a helper instance; fails the test if the scene cannot be loaded.
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
var helper := SceneHelper.new()
helper._suite = suite
var packed: PackedScene = load(scene_path)
if packed == null:
suite.assert_that(packed).override_failure_message(
"SceneHelper: could not load scene at '%s'" % scene_path
).is_not_null()
return helper
helper._scene = packed.instantiate()
suite.auto_free(helper._scene)
suite.add_child(helper._scene)
return helper
## Returns the scene root node.
func scene() -> Node:
return _scene
## Assert that a node at node_path exists under the scene root.
## Fails the current test if the node is absent.
func assert_node_exists(node_path: String) -> void:
var node := _scene.get_node_or_null(NodePath(node_path))
_suite.assert_that(node).override_failure_message(
"SceneHelper: expected node at path '%s' — not found" % node_path
).is_not_null()
## Return the node at node_path under the scene root, or null if absent.
func get_node_at(node_path: String) -> Node:
return _scene.get_node_or_null(NodePath(node_path))
## Begin tracking emissions of signal_name on node.
## Must be called before the action that triggers the signal.
## Fails the test if node does not have the named signal.
func monitor_signal(node: Node, signal_name: String) -> void:
if not node.has_signal(signal_name):
_suite.assert_that(false).override_failure_message(
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
).is_true()
return
var key := _signal_key(node, signal_name)
_signal_hits[key] = 0
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
# GDScript default-param lambdas handle being called with fewer args correctly.
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
_signal_hits[key] = _signal_hits.get(key, 0) + 1
)
## Assert that signal_name was emitted at least once since monitor_signal().
## Fails the test if monitor_signal() was not called first, or if count is zero.
func assert_signal_emitted(node: Node, signal_name: String) -> void:
var key := _signal_key(node, signal_name)
if not _signal_hits.has(key):
_suite.assert_that(false).override_failure_message(
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
).is_true()
return
var count: int = _signal_hits[key]
_suite.assert_int(count).override_failure_message(
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
signal_name, node.name, count
]
).is_greater(0)
static func _signal_key(node: Node, signal_name: String) -> String:
return "%d:%s" % [node.get_instance_id(), signal_name]
+50
View File
@@ -0,0 +1,50 @@
extends Control
## #258: Main menu — New Game / Continue / Quit.
## New Game: generates per-game save directory (D-085), starts game.
## Continue: loads most recent save directory (#554 full loading screen deferred).
const GAME_SCENE := "res://scenes/main.tscn"
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
const TITLE_COLOR := Color("#c8d0e0")
const SUBTITLE_COLOR := Color("#8890a0")
const BTN_NORMAL_COLOR := Color("#e8c547")
const BTN_DISABLED_COLOR := Color("#4a5060")
const FONT_SIZE_TITLE := 36
const FONT_SIZE_SUBTITLE := 14
const FONT_SIZE_BTN := 15
@onready var _new_game_btn: Button = $VBox/NewGameBtn
@onready var _continue_btn: Button = $VBox/ContinueBtn
@onready var _quit_btn: Button = $VBox/QuitBtn
func _ready() -> void:
_new_game_btn.pressed.connect(_on_new_game)
_continue_btn.pressed.connect(_on_continue)
_quit_btn.pressed.connect(_on_quit)
_refresh_continue_state()
func _refresh_continue_state() -> void:
var saves := SessionManager.list_game_dirs()
_continue_btn.disabled = saves.is_empty()
func _on_new_game() -> void:
SessionManager.new_game()
get_tree().change_scene_to_file(GAME_SCENE)
func _on_continue() -> void:
# #554: Full loading screen blocked until server SaveCommand lands.
# For now: automatically load the most recent game directory.
var saves := SessionManager.list_game_dirs()
if saves.is_empty():
return
SessionManager.resume_game(saves[0].game_id)
get_tree().change_scene_to_file(GAME_SCENE)
func _on_quit() -> void:
get_tree().quit()
+13
View File
@@ -121,6 +121,14 @@ func _build_ui() -> void:
close_btn.pressed.connect(close)
_container.add_child(close_btn)
# Quit to Menu button (#258: D-085 session management)
var quit_btn := Button.new()
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
quit_btn.pressed.connect(_on_quit_to_menu)
_container.add_child(quit_btn)
func _destroy_ui() -> void:
if _container:
@@ -153,6 +161,11 @@ func _draw() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
func _on_quit_to_menu() -> void:
close()
SessionManager.quit_to_menu(get_tree().root)
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
+106
View File
@@ -0,0 +1,106 @@
# Fact catalog: ring
# Smuggling ring module event trail — observable evidence produced by module events.
# These facts are made discoverable by specific events in smuggling_ring_v0_1.yaml.
# Stub entries — description and confidence level confirmed; progression text TBD.
# Ticket: #158 | Sprint 18
facts:
- fact_id: ring.cargo_discrepancy_pattern
description: Cargo manifests at The Terminal show small, systematic discrepancies — weight variances, unverified entries, containers with no return log
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Detective's analytical lattice may flag this automatically during Terminal walkthrough.
Smuggler encounters it during operational paperwork checks. Pattern becomes clearer over
multiple observations (once: false event — repeats). Starting entry point for both
investigation arcs.
- fact_id: ring.kael_missed_verification
description: Kael Davan was absent from a scheduled cargo verification — another dock worker covered without explanation
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Discoverable via cargo manifest review (Terminal office) or analytical lattice flag
(detective). Smuggler notices the irregularity in their own paperwork. First concrete
evidence that Kael is deviating from his normal operational reliability.
- fact_id: ring.kael_unauthorized_corridor_access
description: Kael was observed in restricted maintenance corridor B-7 during off-shift hours with an unknown contact
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Core FRIEND contradiction observable (D-034). Player must be in visual range of B-7
to discover this, OR examine the corridor door access log (investigative action).
Both characters can discover this fact — interpreted differently (smuggler: security
breach / detective: suspicious contact). Upgrades from suspects to knows_of when
contact identity confirmed, knows_details when purpose confirmed.
- fact_id: ring.kael_unusual_meridian_activity
description: Encrypted Meridian packets sent from cargo bay terminals — frequent, patterned, not from personal devices
typical_confidence: suspects
characters: [detective]
notes: >
Detective-only. Requires analytical lattice to flag the outgoing packet pattern.
Player must be in or adjacent to The Terminal cargo bay. Content not accessible —
only the existence and frequency of the encrypted messages. Hints at Kael's
off-district contact without revealing who.
- fact_id: ring.voss_kael_private_meeting
description: Voss called Kael into the supervisor's office — closed door, raised voices audible from adjacent position
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Observable via spatial positioning (supervisor's office area). Smuggler can witness
the approach and departure without hearing content. Detective observes Kael's visible
strain immediately after if present in The Terminal. Upgrade to knows_of via
trust-gated dialogue with Kael ("Are you alright?" option unlocks post-meeting).
- fact_id: ring.voss_accelerating_timeline
description: Voss is pushing to close ring operations faster than normal — someone is watching
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Smuggler: Voss mentions "the schedule moving up" in guarded conversation (trust-gated,
ring-insider access required). Detective: observe Voss and Nils in two exchanges within
the same shift and note behavioral change. Not directly stated — inferred from context.
- fact_id: ring.commission_cargo_flag
description: A Commission internal note flags The Terminal's cargo variance rate as statistically unusual
typical_confidence: knows_of
characters: [detective]
notes: >
Detective-only. Accessible via institutional Commission query (authority access tier,
Terminal records). Not a formal investigation — just an internal flag from Maret Korr.
Confirms the detective's suspicions with institutional weight. Smuggler has no way to
know this exists.
- fact_id: ring.final_shipment_scheduled
description: The ring has scheduled a major final drop — after it clears, operations go dark
typical_confidence: knows_of
characters: [smuggler, detective]
notes: >
Smuggler: direct notification from Voss (ring-insider access). Detective: cargo manifest
shows an unusually large entry scheduled 3 days out — no obvious legitimate reason for
the volume. This fact creates the closing window — both characters know time is running
out, for different reasons.
- fact_id: ring.sera_avoidance_behavior
description: Sera Venn takes longer routes through The Last Shift to avoid standing near Torek Lintar
typical_confidence: suspects
characters: [detective]
notes: >
Detective-only observable. Player must observe Sera's movement pattern on two separate
occasions — requires forward vision cone and awareness of her baseline route. The
avoidance is visible but not explained. Points toward Sera holding information about
Torek's investigation without having acted on it.
- fact_id: ring.nils_unlogged_cargo
description: Nils makes regular supply runs to maintenance corridor C-7 with containers that never appear in return logs
typical_confidence: suspects
characters: [smuggler, detective]
notes: >
Discoverable by watching Nils's cargo handling pattern over two shifts, or by examining
the maintenance corridor C-7 access log directly. The missing return entry is the tell —
the container went in but never came back. Both characters can find this; smuggler may
already suspect given operational context.
@@ -0,0 +1,692 @@
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
#
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
# The vertical slice Tier 1 module for D-027.
#
# NARRATIVE CORE:
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
# smuggling unlicensed lattice components through Sova Transit District (D-037).
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
# but hasn't reported them, protecting Naia by proxy.
#
# DUAL-LENS EXPERIENCE:
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
# confront or protect.
#
# SUCCESS CRITERIA (D-027):
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
# #3: Player names Kael as an NPC they felt conflicted about
# #4: observe→notice→follow→discover emerges from systems, not scripts
module_id: smuggling_ring_v0_1
display_name: "The Smuggling Ring"
version: "0.1"
tier: 1
description: >
A small ring of logistics workers smuggling unlicensed lattice components through
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
to protect his partner. The detective investigates cargo anomalies. The smuggler
manages ring operations and navigates Kael's loyalty crisis. Neither character
knows the other's full picture until confrontation forces it.
notes: >
This module IS the vertical slice (D-027). It exercises every system at full depth:
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
No outcome is "the right answer" — Kael's situation has no clean resolution.
dual_lens:
smuggler: >
You're inside the ring. Voss manages operations; you handle logistics cover.
Kael used to be reliable. Lately he's absent, distracted, making excuses.
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
You don't know he's trying to get out. He doesn't know you've noticed.
detective: >
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
Your analytical lattice flags them before your conscious mind does.
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
maintenance corridors → someone he shouldn't be meeting. And then what?
Arrest a man trying to leave a ring he never wanted to join?
pool:
weight: 8
compatible_districts:
- sova-transit
max_concurrent: 1
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
# Ring activity begins after player has had time to establish routine (D-027 #1).
# The ring is already running at game start — the module activates when the
# storyteller decides the tension has built enough to surface.
entry_conditions:
world_state:
- type: npc_present
role: ring-leader
- type: npc_present
role: ring-member-exiting
- type: location_accessible
location: the-terminal
- type: location_accessible
location: maintenance-corridors
activation:
trigger: storyteller_push
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
# The storyteller pushes activation when player has established presence
# in The Terminal or The Last Shift through routine interaction.
# Proximity trigger (maintenance-corridors) is a secondary activation path
# if the player wanders there early.
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
# All core roles are named (hand-authored NPCs from the vertical slice).
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
npc_requirements:
- role: ring-leader
display_hint: >
Runs the ring from The Terminal. Logistics authority = cover.
Never handles contraband directly. Pressure source for Kael.
binding: named
named_npc: "npc:voss"
must_have_motivation: HANDLER
- role: ring-member-exiting
display_hint: >
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
Every event sequence runs through this role.
binding: named
named_npc: "npc:kael-davan"
must_have_pattern: FRIEND
must_have_motivation: TURNCOAT
- role: partner-uninvolved
display_hint: >
Naia Tamm. Kael's partner. Does not know about the ring.
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
Discovery of her connection to Kael is a late-investigation revelation.
binding: named
named_npc: "npc:naia-tamm"
must_have_motivation: CIVILIAN
- role: evidence-holder
display_hint: >
Sera Venn. Detective's FRIEND. Commission field tech.
She has noticed Kael's manifest discrepancies but hasn't reported them —
she knows Naia, and filing means Kael's arrest and Naia's exposure.
Her silence IS the detective's investigation blocker in phase 1.
binding: named
named_npc: "npc:sera-venn"
must_have_pattern: FRIEND
must_have_motivation: WITNESS
- role: ring-operative
display_hint: >
The ring's operational member in maintenance corridors.
Handles physical drops. Not a speaking character — observable behavior only.
Can be the anonymous contact Kael meets.
binding: named
named_npc: "npc:nils-davan"
is_optional: false
- role: institutional-watcher
display_hint: >
Maret Korr. A Commission observer embedded at The Terminal.
Her growing attention is the external pressure that accelerates the timeline.
She doesn't know about the ring specifically — she's tracking cargo patterns.
binding: named
named_npc: "npc:maret-korr"
must_have_motivation: OPERATOR
is_optional: true # Module runs without Maret, but with degraded tension arc
# ── EVENTS ───────────────────────────────────────────────────────────────────
# Two sequences + one pool.
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
# Pool: ambient ring activity (fires opportunistically throughout the module)
events:
sequences:
# SEQUENCE A: Kael's Exit Arc
# The narrative spine. Each step makes Kael's contradiction more visible.
# Observable to both characters, interpreted differently.
- sequence_id: kael_exit_arc
label: "Kael's Exit Arc"
description: >
Kael Davan's progressive attempt to leave the ring.
Tells intensify. Routine deviations appear. The secret meeting is the
pivot point — after it fires, both characters' understanding shifts.
steps:
- event_id: kael_goes_cold
label: "Kael Goes Cold"
description: >
Kael starts missing social patterns he'd normally keep — fewer bar visits,
shorter responses at The Terminal, leaving early. His tell system activates:
the shoulder-check behavior appears. Nothing dramatic. Just absence where
there was presence. The smuggler notices because they work together.
The detective might notice if they've been tracking Kael's baseline.
triggers:
- type: ticks_since_activation
ticks: 300 # ~5 minutes after module activates
effects:
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael skips his usual post-shift drink at The Last Shift.
Leaves the terminal 15 minutes early. No explanation.
- type: tell_intensify
npc_role: ring-member-exiting
description: >
Kael's shoulder-check behavior activates at The Terminal.
Visible to any character with forward vision cone in his direction.
sets_flag: kael_behavior_changed
- event_id: drop_happens_without_kael
label: "Scheduled Drop — Kael Absent"
description: >
A ring drop occurs in maintenance corridor C-7. Kael was supposed
to verify the cargo. He wasn't there. Nils covered it.
The smuggler notices the irregularity in the paperwork.
The detective — if watching cargo patterns — sees a manifest entry
with no verifying signature where one is normally present.
triggers:
- type: ticks_since_event
after_event: kael_goes_cold
ticks: 450 # ~7.5 minutes after goes-cold
effects:
- type: fact_becomes_discoverable
fact_id: "ring.kael_missed_verification"
discoverable_by: any
discovery_method: >
Smuggler: check the cargo manifest in The Terminal office.
Detective: analytical lattice flags unsigned verification entry.
- type: location_state
location: maintenance-corridors
description: "An unsigned cargo verification entry exists in corridor C-7's log."
sets_flag: kael_missed_drop
- event_id: kael_secret_meeting
label: "Kael's Secret Meeting"
description: >
Kael meets an off-district contact in maintenance corridor B-7.
This is the observable contradiction (D-034): Kael, in a restricted
area he has no logged reason to be in, talking to someone who's
not in any district NPC roster. His body language is tense.
If the player is in visual range: this is the pivot moment.
If not: the meeting happens anyway — the world doesn't wait.
triggers:
- type: ticks_since_event
after_event: drop_happens_without_kael
ticks: 600 # ~10 minutes after the dropped verification
- type: player_proximity
target_type: location
target: maintenance-corridors
radius_tiles: 12 # Player wandering near triggers the meeting early
effects:
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael enters maintenance corridor B-7. Locked door to restricted
supply closet. Emerges with the ring-operative 8 minutes later.
Neither acknowledges the encounter publicly.
- type: fact_becomes_discoverable
fact_id: "ring.kael_unauthorized_corridor_access"
discoverable_by: any
discovery_method: >
Player must be in visual range of corridor B-7.
Or examine the corridor door access log (investigative action).
- type: tell_intensify
npc_role: ring-member-exiting
description: >
After the meeting, Kael's shoulder-check frequency doubles.
Also: he avoids eye contact with the smuggler at The Terminal.
sets_flag: secret_meeting_occurred
- event_id: kael_sends_message
label: "Kael Sends the Message"
description: >
Kael sends an encrypted Meridian message to an off-district contact.
The detective's analytical lattice can detect an anomalous outgoing
packet from the district node — not the content, just the pattern
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
The smuggler won't see this unless they're specifically watching Kael.
triggers:
- type: ticks_since_event
after_event: kael_secret_meeting
ticks: 200
effects:
- type: fact_becomes_discoverable
fact_id: "ring.kael_unusual_meridian_activity"
discoverable_by: detective
discovery_method: >
Detective's analytical lattice flags the outgoing packet pattern.
Requires player to be in or adjacent to The Terminal cargo bay.
sets_flag: kael_message_sent
- event_id: ring_leader_confronts_kael
label: "Voss Confronts Kael"
description: >
Voss calls Kael into The Terminal supervisor's office.
Closed door. Raised voices (audible only from adjacent room/position).
Kael emerges pale. Voss emerges neutral. The smuggler can witness
the approach/departure without hearing content. The detective can
observe Kael's state immediately after if in The Terminal.
This is Voss applying pressure. Kael is now visibly under strain.
triggers:
- type: flag_set
flag: kael_message_sent
- type: ticks_since_event
after_event: kael_message_sent
ticks: 400
effects:
- type: npc_routine_deviation
npc_role: ring-leader
description: "Voss calls Kael into the supervisor's office. Door closed."
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael emerges from the meeting looking strained. His shoulder-check
is now constant. He takes an unscheduled break outside, alone.
- type: tell_intensify
npc_role: ring-member-exiting
description: >
Kael's contentment hits lowest observed level. He now actively avoids
the ring-operative (Nils) in public. The disconnection is visible.
- type: fact_becomes_discoverable
fact_id: "ring.voss_kael_private_meeting"
discoverable_by: any
discovery_method: >
Observe the meeting room door (spatial). Or ask Kael directly
after (trust-gated dialogue unlocks "Are you alright?" option).
sets_flag: voss_pressure_applied
# SEQUENCE B: Investigation Pressure Arc
# External pressure that escalates the timeline.
# Fires in parallel with Sequence A.
- sequence_id: investigation_pressure
label: "Investigation Pressure Arc"
description: >
Maret Korr's institutional attention creates a closing window.
Her growing interest is the reason the module can't stay in equilibrium forever.
She doesn't know about the ring — she's a pattern-watcher. But patterns
are what the detective investigates too. Their paths converge.
steps:
- event_id: maret_flags_anomaly
label: "Maret Flags the Cargo Anomaly"
description: >
Maret Korr files an internal Commission note flagging The Terminal's
cargo variance rate as statistically unusual. Not an investigation —
just a flag. The detective's institutional access can pull this note.
The smuggler has no way to know it exists (unless the detective tells them).
triggers:
- type: ticks_since_activation
ticks: 900 # ~15 minutes after activation
effects:
- type: fact_becomes_discoverable
fact_id: "ring.commission_cargo_flag"
discoverable_by: detective
discovery_method: >
Detective queries Commission data via institutional access
(authority access tier, Terminal records).
sets_flag: commission_flag_exists
- event_id: maret_increases_presence
label: "Maret Increases Her Presence"
description: >
Maret starts spending more time in The Terminal. More frequent
walkthroughs during shift changes. Her attention to the cargo bay
area is noticeable to anyone watching. Ring members are unnerved.
Voss starts accelerating the timeline to close operations before
institutional attention becomes formal investigation.
triggers:
- type: ticks_since_event
after_event: maret_flags_anomaly
ticks: 600
- type: player_action
action: examine
target_role: institutional-watcher
effects:
- type: npc_routine_deviation
npc_role: institutional-watcher
description: >
Maret adds two extra Terminal walkthroughs per shift cycle.
Spends 15 minutes studying the cargo bay manifest terminals.
- type: tell_intensify
npc_role: ring-leader
description: >
Voss becomes quieter, more deliberate. Less casual conversation.
His tell — the stillness before speaking — becomes more frequent.
- type: fact_becomes_discoverable
fact_id: "ring.voss_accelerating_timeline"
discoverable_by: any
discovery_method: >
Smuggler: Voss mentions "the schedule moving up" in a guarded
conversation (trust-gated, ring-insider access required).
Detective: observe Voss and Nils in two exchanges within same shift.
sets_flag: timeline_accelerating
- event_id: final_shipment_scheduled
label: "The Final Shipment Is Scheduled"
description: >
The ring schedules the last major drop — after this, they go dark.
This is the closing window. If the detective hasn't uncovered enough
by the time this fires, the ring disperses and the operation closes
without exposure (escaped outcome). If they have, confrontation
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
want to participate. Voss insists.
triggers:
- type: flag_set
flag: timeline_accelerating
- type: ticks_since_event
after_event: maret_increases_presence
ticks: 800
effects:
- type: fact_becomes_discoverable
fact_id: "ring.final_shipment_scheduled"
discoverable_by: any
discovery_method: >
Smuggler: direct notification from Voss.
Detective: cargo manifest shows an unusual large entry for 3 days out.
- type: npc_routine_deviation
npc_role: ring-member-exiting
description: >
Kael's schedule changes: he's assigned to the cargo bay
during the drop window. He doesn't want to be there.
sets_flag: final_shipment_known
pools:
# POOL: Ambient ring activity — opportunistic events that add texture
- pool_id: ambient_ring_activity
label: "Ambient Ring Activity"
description: >
Low-level ring business that happens throughout the module regardless of
player engagement. Creates the sense that the ring exists independently.
Players who look closely will find more; players who don't still feel the world moving.
events:
- event_id: cargo_discrepancy_appears
label: "Small Cargo Discrepancy Appears"
description: >
A minor manifest irregularity appears in The Terminal records.
Small enough to be deniable. Systematic enough to be a pattern.
The detective's analytical lattice may flag it. The smuggler can
correct it if they notice it — covering tracks is part of their role.
triggers:
- type: ticks_since_activation
ticks: 150 # Fires early and repeats
effects:
- type: fact_becomes_discoverable
fact_id: "ring.cargo_discrepancy_pattern"
discoverable_by: any
discovery_method: >
Detective: analytical lattice flags during Terminal walkthrough.
Smuggler: check manifest terminals (or get flagged by the discrepancy
in their own work).
once: false # Repeats — pattern builds over time
- event_id: sera_avoids_torek
label: "Sera Avoids Torek at The Bar"
description: >
Sera Venn reroutes her usual path through The Last Shift to avoid
standing near Torek Lintar (the Commission enforcement officer).
Anyone watching Sera's normal pattern would notice.
This is the detective's first clue that Sera's behavior is odd.
triggers:
- type: ticks_since_activation
ticks: 500
effects:
- type: npc_routine_deviation
npc_role: evidence-holder
description: >
Sera takes a longer route to her usual seat, passing through
the back of the bar to avoid Torek's sightline.
- type: fact_becomes_discoverable
fact_id: "ring.sera_avoidance_behavior"
discoverable_by: detective
discovery_method: >
Observe Sera's path through the bar on two separate occasions.
Requires forward vision cone and awareness of her baseline route.
once: false
- event_id: nils_makes_supply_run
label: "Nils Makes an Unscheduled Supply Run"
description: >
The ring-operative (Nils) enters the maintenance corridors with a
small container logged as "calibration tools". The container isn't
logged for return. Someone paying attention to cargo flow would notice.
triggers:
- type: ticks_since_activation
ticks: 700
effects:
- type: npc_routine_deviation
npc_role: ring-operative
description: "Nils takes a container to maintenance corridor C-7."
- type: fact_becomes_discoverable
fact_id: "ring.nils_unlogged_cargo"
discoverable_by: any
discovery_method: >
Watch Nils's cargo handling pattern over two shifts.
Or examine maintenance corridor C-7 access log.
once: false
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
# Five resolution states. Checked each tick after the first sequence step fires.
# Order matters — the storyteller applies the first matching outcome.
# is_terminal: true ends the module.
outcomes:
# 1. RING EXPOSED
# Detective successfully uncovers the operation.
# Commission becomes involved. Arrests/flight follow.
- outcome_id: ring_exposed
label: "Ring Exposed"
is_terminal: true
description: >
The detective accumulates enough evidence to trigger a formal Commission
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
Kael's situation is now public. The smuggler (if played) faces consequences.
Naia learns what Kael was doing — and why he was trying to leave.
No clean endings. The right outcome for the detective who goes all the way.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern"
- "ring.kael_unauthorized_corridor_access"
- "ring.voss_kael_private_meeting"
flags_set:
- secret_meeting_occurred # set by kael_secret_meeting event
- commission_flag_exists # Commission was watching before exposure
effects:
- type: npc_disposition
npc_role: ring-leader
shift: hostile
description: "Voss is detained or flees. Commission inquiry opens."
- type: npc_disposition
npc_role: ring-member-exiting
shift: hostile
description: >
Kael is arrested or disappears. His exit attempt is now moot.
His relationship with Naia is exposed.
- type: faction_reaction
faction: lattice-commission
reaction: grateful
description: "Commission credits the detective's investigation."
- type: npc_exit
npc_role: ring-leader
description: "Voss leaves the district — detained, fled, or both."
# 2. KAEL ESCAPES THE RING
# Unique path. Requires the player to engage with Kael directly
# and choose to help him rather than expose the ring wholesale.
- outcome_id: kael_escapes
label: "Kael Escapes the Ring"
is_terminal: true
description: >
Through the player's choices — helping Kael cover his exit, or warning him,
or simply choosing not to act on what they know — Kael successfully leaves
the ring before the final shipment. He and Naia leave the district quietly.
The ring continues without him, smaller and more cautious.
This outcome requires discovering Kael's secret AND choosing restraint.
The smuggler can engineer this by covering for Kael with Voss.
The detective can achieve this by confronting Kael privately rather than
filing a report. The most morally complicated path.
conditions:
facts_known:
- "ring.kael_unauthorized_corridor_access"
flags_set:
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
# ring_exposed is checked first in the outcomes list and is terminal,
# so kael_escapes only evaluates if ring_exposed hasn't fired.
# No flags_not_set needed here — outcome ordering handles priority.
effects:
- type: npc_disposition
npc_role: ring-member-exiting
shift: friendly
description: "Kael remembers whoever helped him. He's gone, but grateful."
- type: npc_exit
npc_role: ring-member-exiting
description: "Kael and Naia leave Sova Transit District."
- type: faction_reaction
faction: the-ring
reaction: suspicious
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
# 3. RING COMPLETES OPERATION
# The ring finishes the final shipment and goes dark before discovery.
# Default path if the detective doesn't move fast enough.
- outcome_id: ring_completes
label: "Ring Completes the Operation"
is_terminal: true
description: >
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
Kael stays — he's now out by default, the ring having dissolved around him.
The evidence trail goes cold. The detective closes the case as inconclusive.
The smuggler completes their last run and waits to see if there's another.
Unsatisfying only if you expected a tidy resolution. The world moved on.
conditions:
flags_set:
- final_shipment_known # set by final_shipment_scheduled event
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
facts_not_known:
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
# making this outcome permanently unreachable. Replaced with player-action fact gate.
effects:
- type: faction_reaction
faction: the-ring
reaction: neutral
description: "The ring successfully completed this operation. They'll be back."
- type: npc_exit
npc_role: ring-leader
description: "Voss transfers to another station for 'career development'."
- type: location_access_change
location: maintenance-corridors
change: open
description: "The restricted supply closet is now empty. Access log shows it cleared."
# 4. RING SPLINTERS
# Partial discovery. The ring fractures but doesn't fully collapse.
# An incomplete ending that leaves threads for future investigation.
- outcome_id: ring_splinters
label: "Ring Splinters"
is_terminal: false # Not terminal — splinter state can evolve
description: >
Enough evidence surfaces that the ring knows it's been partially seen.
Voss shuts down active operations. Nils disappears. Kael stays — now the
one person in the district who knows what happened and has no one to tell.
The formal investigation stalls for lack of a clear chain of evidence.
The detective has facts but not the complete picture. The smuggler
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
events_fired:
- kael_goes_cold # event ID — Kael's behavioral shift fired
flags_set:
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
ticks_since_activation: 2400
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
effects:
- type: npc_disposition
npc_role: ring-leader
shift: suspicious
description: "Voss goes quiet. He's watching to see who knows what."
- type: npc_exit
npc_role: ring-operative
description: "Nils stops appearing at The Terminal. Transferred, officially."
- type: faction_reaction
faction: the-ring
reaction: suspicious
description: "The ring is alerted to exposure risk. Future operations will be more careful."
# 5. INVESTIGATION STALLS (post-splinter exit)
# The ring splinters but the detective never breaks through to the pivot evidence.
# Explicit terminal exit for the non-terminal ring_splinters state.
- outcome_id: ring_stalemate
label: "Investigation Stalls"
is_terminal: true
description: >
The ring went dark after the splinter. The detective has the cargo discrepancy
on record — enough to flag, not enough to pursue. The case stays open but cold.
No arrests. No answers. Kael stays in the district, the only person who knows
the full shape of what happened, with no one left to tell it to.
The ring will reconstitute elsewhere. It always does.
conditions:
facts_known:
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
facts_not_known:
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
flags_set:
- kael_missed_drop
- final_shipment_known # ring finished while investigation stalled
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
effects:
- type: faction_reaction
faction: lattice-commission
reaction: neutral
description: "The discrepancy flag stays in Maret's file. No follow-up action."
- type: npc_exit
npc_role: ring-leader
description: "Voss quietly transfers. No announcement, no incident report."
# 6. MODULE EXPIRY (quiet exit)
# Player never engaged at all. Module times out without drama.
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
# Gate expiry on player-action-required facts instead.
- outcome_id: module_abandoned
label: "Module Abandoned"
is_terminal: true
is_expiry: true
description: >
The player never engaged with the ring's signals. The final shipment
completed without incident. The ring disperses on its own schedule.
Kael stays. The world is unchanged. This is not failure — it's the game
acknowledging that not every conspiracy needs a protagonist.
The 70% mundane majority (D-029) plays out: life continued.
conditions:
facts_not_known:
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
effects:
- type: faction_reaction
faction: the-ring
reaction: neutral
description: "The ring closed operations without incident. No record of compromise."
+718
View File
@@ -0,0 +1,718 @@
# Drama Module Schema — Tier 1 Content (D-023)
# YAML expression of JSON Schema 2020-12
# Validated against this schema: content/modules/tier1/*.yaml
#
# Ownership:
# Dramatic structure (this file): Paula
# YAML validation tooling / serde structs: Gestalt / Tyre
# Authoring ergonomics review: Mellanie
#
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "drama_module.schema.yaml"
title: "Tier 1 Drama Module"
description: >
A hand-authored drama module drawn from the pool at game start.
The storyteller activates one or more modules per playthrough based on
entry conditions, then fires events and detects outcomes. Tier 1 modules
are the conspiracy layer of D-023 — authored, optional, relocatable.
type: object
required:
- module_id
- display_name
- version
- tier
- pool
- entry_conditions
- npc_requirements
- events
- outcomes
additionalProperties: false
properties:
# ── IDENTITY ────────────────────────────────────────────────────────────────
module_id:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Stable unique slug. Format: {name}_v{major}_{minor}.
Never reuse IDs. Increment version on breaking structural changes.
Example: "smuggling_ring_v0_1"
display_name:
type: string
minLength: 1
description: "Human-readable title shown in dev/debug tooling."
version:
type: string
pattern: "^[0-9]+\\.[0-9]+$"
description: "Authoring version. Semantic: major.minor."
tier:
type: integer
const: 1
description: "Always 1 for Tier 1 drama modules."
description:
type: string
description: "One-paragraph authoring summary. Not shown in-game."
# ── POOL METADATA ─────────────────────────────────────────────────────────
# Controls how the storyteller includes this module in the per-playthrough pool.
pool:
type: object
required:
- weight
additionalProperties: false
description: "How the storyteller samples this module from the pool."
properties:
weight:
type: integer
minimum: 1
maximum: 10
description: >
Relative selection probability (1–10). Higher = more likely to be
included in a given playthrough's active module set. Default: 5.
compatible_districts:
type: array
items:
type: string
description: >
District slugs where this module can activate, or omit for "any".
Example: ["sova-transit"]
incompatible_with:
type: array
items:
type: string
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
description: >
Module IDs that cannot run concurrently with this one.
The storyteller will not activate both in the same playthrough.
max_concurrent:
type: integer
minimum: 1
default: 1
description: >
Maximum simultaneous active instances. Almost always 1.
Set to 2+ only for modules designed to stack (rare).
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
# All listed conditions must be true for the module to become activatable.
# The storyteller checks these each tick after min_play_ticks.
entry_conditions:
type: object
required:
- activation
additionalProperties: false
description: >
World-state prerequisites. The storyteller activates the module when
ALL conditions are satisfied AND the activation trigger fires.
properties:
world_state:
type: array
items:
$ref: "#/$defs/world_state_condition"
description: "World-state conditions checked each tick."
player:
type: array
items:
$ref: "#/$defs/player_condition"
description: >
Optional player-state conditions. Module can activate without
player engagement — these gate on player-specific world state,
not on player noticing the module.
activation:
type: object
required:
- trigger
additionalProperties: false
description: "How and when activation is evaluated."
properties:
trigger:
type: string
enum:
- proximity # Player comes within range of a key NPC/location
- storyteller_push # Storyteller activates on its own schedule
- player_action # Player performs a specific action
description: "What pushes the module from 'eligible' to 'active'."
min_play_ticks:
type: integer
minimum: 0
description: >
Minimum ticks of game time before this module can activate.
Enforces D-027 success criterion #1: 30 minutes of daily-life
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
proximity_location:
type: string
description: >
Required when trigger = proximity. Location slug the player
must enter or approach. Example: "maintenance-corridors"
proximity_radius_tiles:
type: integer
minimum: 1
description: >
Required when trigger = proximity. Tile radius around the
location's anchor point.
player_action_required:
type: string
description: >
Required when trigger = player_action. The action that fires
activation. Example: "examine:cargo-manifest"
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
# NPC slots this module requires. Each slot is filled at module load time.
# Named bindings resolve to specific authored NPCs; generated bindings
# are filled from the district's generated NPC pool.
npc_requirements:
type: array
minItems: 1
items:
$ref: "#/$defs/npc_slot"
description: >
Module-internal NPC role slots. Roles are referenced by slug throughout
the rest of this document. Hand-authored NPCs use named bindings.
Generated NPCs use constraint-based bindings.
# ── EVENTS ────────────────────────────────────────────────────────────────
# Ordered sequences and unordered event pools the storyteller can fire.
# Sequences are narrative beats in a defined order.
# Pools are events the storyteller can fire in any order when conditions are met.
events:
type: object
additionalProperties: false
description: "Event sequences and pools the storyteller manages."
properties:
sequences:
type: array
items:
$ref: "#/$defs/event_sequence"
description: >
Ordered event sequences. Steps fire in order; the next step
becomes eligible only after the previous one fires.
pools:
type: array
items:
$ref: "#/$defs/event_pool"
description: >
Unordered event pools. The storyteller may fire any eligible
event in the pool when its trigger conditions are met.
# ── OUTCOMES ──────────────────────────────────────────────────────────────
# Resolution states the module can reach. The storyteller checks outcome
# conditions each tick. First matching outcome wins.
# Every module MUST include an expiry outcome.
outcomes:
type: array
minItems: 1
items:
$ref: "#/$defs/outcome"
description: >
Terminal and transitional resolution states. The storyteller checks
these each tick and applies the first matching outcome.
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
notes:
type: string
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
dual_lens:
type: object
additionalProperties: false
description: "Authoring-only. How smuggler vs detective experience this module."
properties:
smuggler: { type: string }
detective: { type: string }
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
$defs:
# World-state condition types
world_state_condition:
type: object
required:
- type
description: "A single world-state prerequisite for module activation."
oneOf:
- # NPC with the given module role is present in the district
properties:
type: { type: string, const: "npc_present" }
role: { type: string, description: "Module-internal NPC role slug." }
required: [type, role]
additionalProperties: false
- # A specific location is accessible to the player
properties:
type: { type: string, const: "location_accessible" }
location: { type: string, description: "Location slug." }
required: [type, location]
additionalProperties: false
- # Player has NOT yet discovered a specific fact
properties:
type: { type: string, const: "fact_not_known" }
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
required: [type, fact_id]
additionalProperties: false
- # No other Tier 1 module of the given ID is currently active
properties:
type: { type: string, const: "no_active_module" }
module_id: { type: string }
required: [type, module_id]
additionalProperties: false
- # A named fact IS known (module requires precondition awareness)
properties:
type: { type: string, const: "fact_known" }
fact_id: { type: string }
known_by: { type: string, enum: [smuggler, detective, any] }
required: [type, fact_id]
additionalProperties: false
# Player-state condition types
player_condition:
type: object
required:
- type
description: "A player-state prerequisite."
oneOf:
- # Player has reached minimum relationship threshold with an NPC
properties:
type: { type: string, const: "relationship_threshold" }
npc_role: { type: string, description: "Module-internal NPC role." }
min_state:
type: string
enum: [stranger, known, friendly]
description: "Minimum RelationshipState required."
required: [type, npc_role, min_state]
additionalProperties: false
- # Minimum game ticks elapsed
properties:
type: { type: string, const: "min_ticks" }
ticks: { type: integer, minimum: 0 }
required: [type, ticks]
additionalProperties: false
# NPC slot definition
npc_slot:
type: object
required:
- role
- binding
additionalProperties: false
description: >
One NPC slot in the module. Named binding = specific authored NPC.
Generated binding = constraint-matched NPC from district pool.
properties:
role:
type: string
pattern: "^[a-z][a-z0-9-]*$"
description: >
Module-internal role slug. Referenced in events, outcomes, and
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
display_hint:
type: string
description: "Authoring note. What this role is narratively."
binding:
type: string
enum: [named, generated]
description: >
named = resolves to a specific authored NPC (use named_npc).
generated = any district NPC matching the axis constraints.
named_npc:
type: string
pattern: "^npc:[a-z][a-z0-9-]*$"
description: >
Required when binding = named. Short-form NPC canonical ID.
Example: "npc:kael-davan"
axes:
type: array
items:
$ref: "#/$defs/axis_constraint"
description: >
Required when binding = generated. The NPC must satisfy all
listed axis constraints to fill this slot.
must_have_pattern:
type: string
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
description: "Optional: NPC must have this pattern (D-024)."
must_have_motivation:
type: string
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
description: "Optional: NPC must have this motivation (D-024)."
is_optional:
type: boolean
default: false
description: >
If true, the module can activate without this slot filled.
Optional slots produce degraded but valid module runs.
# NPC axis constraint (used in generated bindings)
axis_constraint:
type: object
required:
- axis
- constraint
additionalProperties: false
properties:
axis:
type: string
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
description: "Which NPC axis to constrain (D-024)."
constraint:
type: string
description: >
Constraint expression. Freeform string interpreted by the storyteller.
Convention: "has_{value}", "min_{N}", "not_{value}".
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
# Event sequence
event_sequence:
type: object
required:
- sequence_id
- steps
additionalProperties: false
description: "An ordered sequence of narrative events."
properties:
sequence_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
steps:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Unordered event pool
event_pool:
type: object
required:
- pool_id
- events
additionalProperties: false
properties:
pool_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
events:
type: array
minItems: 1
items:
$ref: "#/$defs/event_step"
# Individual event step
event_step:
type: object
required:
- event_id
- triggers
additionalProperties: false
description: "A single storyteller-managed event with triggers and effects."
properties:
event_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Unique within this module. Used in outcome conditions."
label:
type: string
description:
type: string
description: "What happens narratively when this event fires."
triggers:
type: array
minItems: 1
items:
$ref: "#/$defs/event_trigger"
description: "ANY trigger being true fires this event."
effects:
type: array
items:
$ref: "#/$defs/event_effect"
description: "What changes in the world when this event fires."
once:
type: boolean
default: true
description: "If true, fires only once. If false, may repeat when conditions reset."
sets_flag:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
description: "Module-internal flag set when this event fires. Queryable in outcomes."
# Event trigger conditions
event_trigger:
type: object
required:
- type
description: "A condition that causes an event to fire."
oneOf:
- # Ticks elapsed since module activation
properties:
type: { type: string, const: "ticks_since_activation" }
ticks: { type: integer, minimum: 1 }
required: [type, ticks]
additionalProperties: false
- # Ticks elapsed since a previous event fired
properties:
type: { type: string, const: "ticks_since_event" }
after_event: { type: string }
ticks: { type: integer, minimum: 1 }
required: [type, after_event, ticks]
additionalProperties: false
- # Player enters a location or comes within range of NPC
properties:
type: { type: string, const: "player_proximity" }
target_type: { type: string, enum: [location, npc_role] }
target: { type: string }
radius_tiles: { type: integer, minimum: 1 }
required: [type, target_type, target]
additionalProperties: false
- # Player performs an interaction
properties:
type: { type: string, const: "player_action" }
action:
type: string
enum: [talk, examine, confront, follow, observe]
target_role: { type: string, description: "Module NPC role or location slug." }
required: [type, action, target_role]
additionalProperties: false
- # Player has discovered a specific fact
properties:
type: { type: string, const: "fact_known_by_player" }
fact_id: { type: string }
required: [type, fact_id]
additionalProperties: false
- # A module flag has been set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string }
required: [type, flag]
additionalProperties: false
- # NPC enters a specific mood state
properties:
type: { type: string, const: "npc_mood" }
npc_role: { type: string }
mood:
type: string
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
required: [type, npc_role, mood]
additionalProperties: false
# Event effects
event_effect:
type: object
required:
- type
description: "A world change triggered by an event."
oneOf:
- # NPC deviates from their normal routine
properties:
type: { type: string, const: "npc_routine_deviation" }
npc_role: { type: string }
description: { type: string, description: "What the deviation looks like." }
duration_ticks: { type: integer }
required: [type, npc_role, description]
additionalProperties: false
- # A fact becomes discoverable (moves to Rumoured confidence)
properties:
type: { type: string, const: "fact_becomes_discoverable" }
fact_id: { type: string }
discoverable_by:
type: string
enum: [smuggler, detective, any]
discovery_method:
type: string
description: "How the player can discover this. Authoring note."
required: [type, fact_id, discoverable_by]
additionalProperties: false
- # NPC tell behavior becomes more pronounced
properties:
type: { type: string, const: "tell_intensify" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
- # A module-internal flag is set
properties:
type: { type: string, const: "flag_set" }
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
required: [type, flag]
additionalProperties: false
- # Something changes about a location
properties:
type: { type: string, const: "location_state" }
location: { type: string }
description: { type: string }
required: [type, location, description]
additionalProperties: false
- # NPC's access to information changes
properties:
type: { type: string, const: "npc_knowledge_update" }
npc_role: { type: string }
fact_id: { type: string }
description: { type: string }
required: [type, npc_role, fact_id]
additionalProperties: false
# Module outcome definition
outcome:
type: object
required:
- outcome_id
- label
- is_terminal
additionalProperties: false
description: >
A resolution state the module can reach. Conditions are checked each tick.
The first matching outcome is applied. is_terminal = true ends the module.
properties:
outcome_id:
type: string
pattern: "^[a-z][a-z0-9_-]*$"
label:
type: string
description:
type: string
description: "What this outcome means narratively."
is_terminal:
type: boolean
description: "If true, this outcome ends the module permanently."
is_expiry:
type: boolean
default: false
description: >
If true, this is the quiet-exit outcome when the player never engages.
Every module must include exactly one expiry outcome.
conditions:
type: object
additionalProperties: false
description: "ALL conditions must be true to reach this outcome."
properties:
facts_known:
type: array
items: { type: string }
description: "Player must know all these facts."
facts_not_known:
type: array
items: { type: string }
description: "Player must NOT know any of these facts."
flags_set:
type: array
items: { type: string }
description: "All these module flags must be set."
flags_not_set:
type: array
items: { type: string }
description: "None of these module flags may be set."
events_fired:
type: array
items: { type: string }
description: "All these events must have fired."
ticks_since_activation:
type: integer
description: "Module has been active for at least this many ticks."
effects:
type: array
items:
$ref: "#/$defs/outcome_effect"
description: "Effects applied when this outcome is reached."
# Outcome-level effects (broader scope than event effects)
outcome_effect:
type: object
required:
- type
oneOf:
- # NPC disposition toward player changes
properties:
type: { type: string, const: "npc_disposition" }
npc_role: { type: string }
shift:
type: string
enum: [hostile, suspicious, neutral, friendly]
description: { type: string }
required: [type, npc_role, shift]
additionalProperties: false
- # Faction reaction
properties:
type: { type: string, const: "faction_reaction" }
faction: { type: string }
reaction:
type: string
enum: [hostile, suspicious, neutral, friendly, grateful]
description: { type: string }
required: [type, faction, reaction]
additionalProperties: false
- # Location becomes restricted or opens up
properties:
type: { type: string, const: "location_access_change" }
location: { type: string }
change:
type: string
enum: [restricted, locked, open]
description: { type: string }
required: [type, location, change]
additionalProperties: false
- # A fact is now permanently known/unknown
properties:
type: { type: string, const: "fact_state" }
fact_id: { type: string }
state:
type: string
enum: [known, hidden, destroyed]
description: { type: string }
required: [type, fact_id, state]
additionalProperties: false
- # NPC leaves the district or changes role
properties:
type: { type: string, const: "npc_exit" }
npc_role: { type: string }
description: { type: string }
required: [type, npc_role]
additionalProperties: false
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Decision ID management — claim, query, and validate decision IDs.
# Usage:
# decision next [D|Q|R] Show next available ID
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
# decision check-dupes Check for duplicate IDs in markdown
# decision sync Sync markdown -> DB
exec python3 "$(dirname "$0")/decisions_sync.py" "$@"
+131 -8
View File
@@ -366,22 +366,133 @@ def sync(cfg):
conn.close()
# ---------------------------------------------------------------------------
# ID claiming — database is authority for ID allocation
# ---------------------------------------------------------------------------
def next_id(cfg, prefix=None):
"""Return the next available ID for a given prefix (D, Q, R) or all."""
conn = get_connection(cfg)
try:
result = {}
prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"]
for p in prefixes:
# Check both DB and markdown files for the highest ID
row = conn.execute(
"SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num "
"FROM decisions WHERE id LIKE ?",
(f"{p}-%",),
).fetchone()
db_max = row["max_num"] if row and row["max_num"] else 0
# Also scan markdown files in case they're ahead of the DB
md_max = 0
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
if filepath.name.lower() == "readme.md":
continue
text = filepath.read_text(encoding="utf-8")
for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE):
num = int(m.group(1))
if num > md_max:
md_max = num
highest = max(db_max, md_max)
next_num = highest + 1
result[p] = f"{p}-{next_num:03d}"
return {"ok": True, **result}
finally:
conn.close()
def claim_id(cfg, prefix, domain, title):
"""Claim the next available ID and insert a placeholder into the DB."""
if prefix not in ("D", "Q", "R"):
return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."}
type_map = {"D": "confirmed", "Q": "question", "R": "rejected"}
status_map = {"D": "active", "Q": "open", "R": "active"}
nxt = next_id(cfg, prefix)
if not nxt.get("ok"):
return nxt
new_id = nxt[prefix]
conn = get_connection(cfg)
try:
conn.execute(
"""INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
(new_id, type_map[prefix], domain, title, status_map[prefix],
f"decisions/{domain}.md"),
)
conn.commit()
return {"ok": True, "id": new_id, "domain": domain, "title": title}
except sqlite3.IntegrityError as exc:
conn.rollback()
return {"ok": False, "error": f"ID conflict: {exc}"}
finally:
conn.close()
def check_dupes(cfg):
"""Check for duplicate decision IDs across all markdown files."""
# Pre-existing collisions too deeply embedded to renumber (139+ references).
# New collisions are prevented by the claim workflow.
KNOWN_EXCEPTIONS = {"D-035"}
id_locations = {} # id -> [(file, line_number)]
warnings = []
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
if filepath.name.lower() == "readme.md":
continue
text = filepath.read_text(encoding="utf-8")
for i, line in enumerate(text.split("\n"), 1):
m = HEADING_RE.match(line)
if m:
did = m.group(1)
if did not in id_locations:
id_locations[did] = []
id_locations[did].append((filepath.name, i))
dupes = {did: locs for did, locs in id_locations.items()
if len(locs) > 1 and did not in KNOWN_EXCEPTIONS}
if dupes:
for did, locs in sorted(dupes.items()):
loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs)
warnings.append(f"DUPLICATE {did}: {loc_str}")
return {
"ok": len(dupes) == 0,
"total_ids": len(id_locations),
"duplicates": len(dupes),
"known_exceptions": list(KNOWN_EXCEPTIONS),
"details": warnings,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
HELP_TEXT = """\
Commonwealth Decisions Sync
Commonwealth Decisions Sync & ID Management
Usage:
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
decisions_sync.py --help Show this help message
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one)
decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder
decisions_sync.py check-dupes Check for duplicate IDs across markdown files
decisions_sync.py --help Show this help message
Parses all markdown files in decisions/ (excluding README.md), extracts
decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions
and decision_refs tables.
Idempotent: safe to run repeatedly. References are rebuilt on every sync.
ID claiming workflow:
1. Agent calls 'claim D architecture "Per-game save dirs"'
2. Gets back D-085 (or whatever is next)
3. Agent writes D-085 in the appropriate domain file
4. Pre-commit hook runs check-dupes to catch collisions
Config: {config}
Schema: {schema}
@@ -404,6 +515,18 @@ def main():
if cmd == "sync":
result = sync(cfg)
elif cmd == "next":
result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None)
elif cmd == "claim":
if len(sys.argv) < 4:
result = {"ok": False, "error": "Usage: claim <D|Q|R> <domain> [title]"}
else:
prefix = sys.argv[2].upper()
domain = sys.argv[3]
title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)"
result = claim_id(cfg, prefix, domain, title)
elif cmd == "check-dupes":
result = check_dupes(cfg)
else:
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
+4 -4
View File
@@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 |
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-026 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-029 |
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
## Querying Decisions
+16 -1
View File
@@ -210,4 +210,19 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*16 decisions. Last updated: 2026-02-16*
### D-085: Per-game save directory structure
- **Date:** 2026-02-25
- **Decision:** Every new game creates a dedicated directory under the user save path. All saves for that game (manual, quicksave, autosave) live inside the game's directory. Directory name includes a human-readable game identifier and creation timestamp.
- **Rationale:** Natively groups saves by game without requiring a database or index file. Players can browse, back up, or delete game saves at the filesystem level. Avoids a flat save folder where 50+ files from different games are interleaved.
- **Structure:** `user://saves/<game-id>/` where `<game-id>` is `<timestamp>-<seed>` (e.g., `20260225-143022-a7b3f1/`). Inside: `quicksave.sav`, `autosave.sav`, `manual_001.sav`, etc.
- **Constraints:**
- Game directory created on "New Game" — even before the first save, so the path exists for quicksave/autosave.
- F5 = quicksave (overwrites `quicksave.sav` in the active game dir).
- F6 = quickload (loads `quicksave.sav` from the active game dir).
- Loading screen lists game directories sorted by last-modified, shows most recent save per game.
- **Raised by:** Team Leader (Jeroen)
- **Dissent:** None
---
*17 decisions. Last updated: 2026-02-25*
+23 -1
View File
@@ -182,6 +182,28 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Tyre (technical analysis, architecture synthesis)
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
### D-084: Dual-namespace line ID scheme — role pool + instance override
- **Date:** 2026-02-25
- **Decision:** Line IDs for auto-generated NPC content use a dual-namespace approach that eliminates the Q-028 collision problem without altering the existing ID format.
- **Role namespace (unchanged, primary):** `{role-slug}_{d|m|e}_{###}` — e.g., `dock-worker_d_001`. These are shared lines delivered by any instance of the role. One file, one ID sequence per role-at-location. No per-instance authoring, no collision possible. All existing authored content is unaffected.
- **Instance namespace (new, opt-in):** `{role-slug}-{zero-padded counter}_{d|m|e}_{###}` — e.g., `dock-worker-07_d_001`. Counter is the generation-order rank within the role group for that district, starting at 01, assigned deterministically from the world seed. Used only when a specific generated NPC needs authored content that differs from the role pool.
- **Key design choices:**
- **Why the collision problem is mostly already solved:** The Q-028 collision framing assumed NPC-scoped IDs require per-instance ID sequences. They don't. D-028 tagged line pools are role-scoped: `dock-worker_d_001` is content that any dock worker can deliver. Forty dock workers all drawing from `dock-worker_d_###` is correct behavior, not a collision. A collision would only occur if two *distinct authored lines* shared the same ID — which the role namespace prevents by definition (one file, one sequence).
- **Instance namespace scope:** Opt-in only. Tier 3 (flat wallpaper) and Tier 2 (mundane triangles) auto-generated NPCs use the role pool exclusively. Instance pools are authored only when a specific generated NPC needs content variation the role pool cannot supply (e.g., a generated NPC flagged as a triangle member with unique tell lines).
- **History log disambiguation:** The speaker of a line is identified by `StableId`, not by line ID. Line ID identifies content; `StableId` identifies the speaker. `(StableId: 42, line_id: "dock-worker_d_001")` and `(StableId: 43, line_id: "dock-worker_d_001")` are two different log entries for the same content line — no collision in the log.
- **Counter stability:** Generation order within a role group is seeded from the world seed. Same seed → same order → same counter assignments. Counter is recorded in the district's NPC roster at world-gen time. The counter survives save/load because it is part of the generated NPC's profile, not recomputed at runtime.
- **Schema compatibility:** The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` already accepts `dock-worker-07_d_001`. No regex change required. No content migration required.
- **Rejected alternatives:**
- **StableId prefix (`npc-00042_d_001`):** StableId is assigned at load time from sorted canonical IDs. Authors cannot know it before writing files. A generate-then-bake pipeline would break the content/generation separation principle. Rejected.
- **UUID suffix (`dock-worker-a3f2_d_001`):** UUIDs are stable per seed but change across seeds, orphaning any authored instance content on replay. Rejected.
- **Slug registry with collision resolution (`dock-worker`, `dock-worker-2`, ...):** First instance gets a privileged non-suffixed slug while all others get a counter, creating asymmetry with no upside. Rejected.
- **Implementation requirement:** The content registry (`server/src/knowledge/registry.rs` or a new `server/src/content/npc_slug.rs`) tracks a `RoleCounter: BTreeMap<String, u32>` per district. Incremented when a generated NPC claims an instance namespace slot. Stored in the district manifest. Provides `generate_instance_slug(role_slug) -> String` returning `{role-slug}-{counter:02}`.
- **Hand-authored NPCs:** Unchanged. `kael-davan`, `sera-venn`, and all named authored NPCs keep their current slugs and ID sequences. No migration.
- **Resolves:** Q-028
- **Cross-reference:** Line ID scheme ([D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools)), population model ([D-029](#d-029-population-entanglement-ratio--305020)), NPC generation ([D-024](#d-024-npc-generation-model--10-axes--combat-component))
- **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review.
- **Dissent:** None.
---
*16 decisions. Last updated: 2026-02-19 (D-075 dissent updated)*
*17 decisions. Last updated: 2026-02-25 (D-084 added — Q-028 resolution)*
+20 -1
View File
@@ -446,6 +446,25 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
### D-086: Insert icon system — custom SVG, no icon font
- **Date:** 2026-02-24
- **Decision:** The insert overlay (z-layer 6) uses **custom SVG icons**, not an icon font. The v0.1 icon vocabulary (~12–15 shapes: stance indicators, perception mode icons, inventory item silhouettes, border arrows) is too small and too specific for a font. No available icon font (Material Symbols, Phosphor, Feather, Tabler) matches the insert's geometric/diegetic visual language — they all read as "app UI," not "neural lattice overlay." Custom SVGs authored to the insert's constraint set (clean geometry, minimal anchor points, `#c8d0e0` chrome color, 1px stroke base weight) ensure all insert elements feel generated by the same system.
- **Why not an icon font:**
- **Vocabulary mismatch:** No font has "hexagonal corridor access token" or "thermal perception mode" in a sci-fi register. Inventory silhouettes are universe-specific — we author them regardless.
- **Weight precision:** The spec defines 1px (smuggler/baseline) vs 1–2px (detective/augmented) line weight. Custom SVG `stroke-width` responds to `lattice_profile` parameter directly. Icon fonts can't vary weight per-character in a single rendering pass.
- **Visual coherence:** Mixing a generic icon font with the custom geometric primitives (diamond POI markers, 6px dots, 4px evidence squares, commission grid) creates two visual registers on the same UI layer. All insert elements must feel like they came from one design system.
- **Godot integration:** Godot 4 handles SVG natively (AtlasTexture, importable SVGs). Runtime color via ShaderMaterial. No font rendering pipeline or bitmap caching needed.
- **Icon vocabulary (v0.1):**
- Inventory item silhouettes: 3–4 items at 40×40px (manifest, access token, comm log) — smuggler only per [D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence)
- Perception mode indicators: 2–3 icons at 20×20px (deferred until #315 specs the modes)
- Stance indicators: text labels (`WALK`, `CAREFUL`, `SPRINT`, `CROUCH`) at Michroma 11px — icons deferred to v0.2
- Border arrows and geometric markers (diamonds, dots, squares): drawn as primitives, not icons
- **Authoring constraints:** 1px stroke weight base (scaled to 2px for `lattice_augmented` via parameter), `#c8d0e0` as design reference color (runtime-replaceable), minimal anchor points, no decorative curves.
- **Calibration reference:** Phosphor Light (MIT) — not for use, but for evaluating small-size readability of custom shapes during authoring. If a custom icon doesn't read as cleanly as a Phosphor Light equivalent at 16px, simplify it.
- **Cross-reference:** Insert overlay ([D-048](#d-048-neural-insert-overlay--visual-design)), insert HUD wireframe (`docs/design/insert-hud-wireframe-v01.md`), visual grammar (`docs/design/visual-grammar-v01.md` §7), cursor states ([D-056](#d-056-cursor-states--insert-styled-geometric)), inventory ([D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence))
- **Raised by:** Araminta (analysis and recommendation), project lead (confirmed).
- **Dissent:** None.
---
*37 decisions. Last updated: 2026-02-24 (D-079–D-083: Knowledge Flow & NPC Information Boundaries Workshop)*
*38 decisions. Last updated: 2026-02-24 (D-086: Insert icon system)*
+18 -4
View File
@@ -161,13 +161,27 @@ Tracked questions awaiting discussion or resolution.
- **Source:** Sprint 10 PR review discussion (2026-02-19)
### Q-028: Collision-resistant line IDs for auto-generated NPCs
- **Status:** Open
- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content.
- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace.
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
- **Ticket:** #544
- **Assigned to:** Gestalt, Tyre
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
### Q-029: Save file format design
- **Status:** Open
- **Question:** What should the long-term save file format look like? Key considerations:
1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load?
2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward?
3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header?
4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save?
5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable?
6. **Modding:** Should the format be documented for mod authors? Does it need extension points?
7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits?
- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality.
- **Assigned to:** Tyre, Dudley
- **Source:** Team Leader directive (Sprint 19 planning)
---
*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24*
*29 questions (7 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-25 (Q-029 added)*
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
# Line ID Authoring Guide
**Decision:** D-084 (dual-namespace line ID scheme)
**Resolves:** Q-028 (collision-resistant IDs for auto-generated NPCs)
**Ticket:** #544
---
## The Short Version
- **Role pool lines:** Use `{role-slug}_d_{###}` — e.g., `dock-worker_d_001`. These lines are shared by all NPCs with that role. This is the default for all auto-generated NPC content.
- **Named NPC lines:** Use `{npc-slug}_d_{###}` — e.g., `kael-davan_d_001`. Unchanged from current practice.
- **Instance-specific lines (rare):** Use `{role-slug}-{counter}_d_{###}` — e.g., `dock-worker-07_d_001`. Only needed when a specific generated NPC needs content different from the role pool.
---
## How Line IDs Work
A line ID identifies **content**, not speaker. The speaker is identified by their `StableId` in the history log. So `dock-worker_d_001` being said by 40 different dock workers is correct: the log records `(StableId: 12, dock-worker_d_001)`, `(StableId: 37, dock-worker_d_001)`, etc. No collision.
This means the role pool approach already handles most cases — the "collision problem" is mainly a concern for the rare case where you want a specific generated NPC to say something *different* from others of the same role.
---
## Namespace Reference
### Named NPC lines (Tier 1 and Tier 2 authored NPCs)
```
Format: {npc-slug}_{content-type}_{###}
Example: kael-davan_d_001 (Kael's dialogue line 1)
sera-venn_d_015 (Sera's dialogue line 15)
pc-smuggler_m_s_001 (Smuggler monologue line 1)
```
File location: One file per NPC (e.g., `dialogue/maintenance-corridors/kael-davan.yaml`)
Numbering: Sequential within the file. Gaps are acceptable (deleted lines leave permanent gaps). Never reuse a number.
---
### Role pool lines (auto-generated NPCs, Tier 3 flat, Tier 2 mundane)
```
Format: {role-slug}_{content-type}_{###}
Example: dock-worker_d_001 (any dock worker, dialogue line 1)
bar-regular_d_008 (any bar regular, dialogue line 8)
transit-worker_d_003 (any transit worker, dialogue line 3)
```
File location: One file per role-at-location (e.g., `dialogue/the-terminal/dock-worker.yaml`)
These lines are shared by **all instances** of the role. Write them to suit any dock worker, not a specific one.
---
### Instance-specific lines (opt-in, rare)
Use only when the generation system has flagged a specific NPC as needing content that differs from the role pool. Examples: a generated dock worker who is also a triangle member with a specific tell; a generated bar regular who witnessed a specific event.
```
Format: {role-slug}-{zero-padded counter}_{content-type}_{###}
Example: dock-worker-07_d_001 (instance 7 of dock-worker role, line 1)
bar-regular-02_d_005 (instance 2 of bar-regular role, line 5)
```
The counter (01, 02, ... N) is assigned by the generation system in world-seed-deterministic order. The NPC's generated profile file will tell you which counter to use.
File location: Same directory as the role pool file, separate file with instance slug as name (e.g., `dialogue/the-terminal/dock-worker-07.yaml`)
---
## Quick Decision Guide
| Situation | ID format to use |
|-----------|------------------|
| Named authored NPC (Kael, Sera, Voss...) | `{npc-slug}_d_{###}` |
| Lines any dock worker can say | `dock-worker_d_{###}` |
| Lines any bar regular can say | `bar-regular_d_{###}` |
| Generated NPC with specific triangle role | `{role-slug}-{counter}_d_{###}` |
| Generated NPC who's just background | `{role-slug}_d_{###}` — no instance ID needed |
---
## Schema Compatibility
The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` accepts all three formats. No schema change is required. The content validator (`make validate-content`) checks for duplicate IDs across all files in a district.
---
## Numbering Rules
1. Start at `001`, increment by 1 for each new line.
2. Never reuse a number, even if a line is deleted. Gaps are fine.
3. Lines within a single file have a contiguous prefix — `dock-worker_d_001` through `dock-worker_d_042`, etc.
4. Cross-file: `kael-davan.yaml` at the terminal and `kael-davan.yaml` at maintenance corridors both use the `kael-davan_d_###` namespace. Continue numbering from where the other file left off (check the existing files first, use a fresh sequence if the NPC is new to a location).
---
*D-084 — authored by Gestalt, Sprint 18*
+450
View File
@@ -0,0 +1,450 @@
# Tier 1 Drama Module — Authoring Guide
**Schema:** `content/schemas/drama_module.schema.yaml`
**Module pool:** `content/modules/tier1/*.yaml`
**Decisions:** D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern)
**Vertical slice reference:** `content/modules/tier1/smuggling_ring_v0_1.yaml`
---
## What Is a Tier 1 Drama Module?
Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are:
- **Hand-authored.** Every event sequence, every NPC role, every outcome was written by a person.
- **Pool-based.** Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions.
- **Optional from the player's perspective.** The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from *systems*, not *scripts*.
- **Dual-lens.** Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes.
What they are **not:**
- Not quests with markers or objectives.
- Not scripted cutscenes.
- Not balanced challenge encounters.
The storyteller uses the module as a *schedule* — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them.
---
## File Structure
```
content/
schemas/
drama_module.schema.yaml ← Schema reference (this file validates against it)
modules/
tier1/
smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module
future_module_v0_1.yaml ← Future modules go here
```
One `.yaml` file per drama module. The storyteller's content loader scans `content/modules/tier1/` at startup and adds all valid modules to the pool.
---
## Field Reference
### Identity Fields
| Field | Required | Description |
|-------|----------|-------------|
| `module_id` | Yes | Stable slug: `{name}_v{major}_{minor}`. Never reuse. Increment on breaking structural change. |
| `display_name` | Yes | Human-readable title for dev tooling. Not shown in-game. |
| `version` | Yes | Authoring version: `{major}.{minor}`. |
| `tier` | Yes | Always `1`. |
| `description` | No | One-paragraph design summary. Authoring-only. |
| `notes` | No | Design rationale, cross-references. Ignored at load time. |
| `dual_lens` | No | How smuggler vs detective experience this module. Authoring-only. **Write this first** — it disciplines the design. |
---
### Pool Metadata
Controls how the storyteller samples this module.
| Field | Required | Description |
|-------|----------|-------------|
| `pool.weight` | Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. |
| `pool.compatible_districts` | No | District slugs. Omit for "any". |
| `pool.incompatible_with` | No | Module IDs that can't run concurrently. |
| `pool.max_concurrent` | No | Almost always 1. |
**Design note on weight:** Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit."
---
### Entry Conditions
Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines *how* it fires.
#### World-State Condition Types
| Type | Required Fields | Use When |
|------|----------------|----------|
| `npc_present` | `role` | The module requires a specific NPC to be in the district. |
| `location_accessible` | `location` | The module requires a location the player can physically reach. |
| `fact_not_known` | `fact_id` | Module shouldn't activate if a precondition has already been discovered. |
| `no_active_module` | `module_id` | Prevents two incompatible modules running at once. |
| `fact_known` | `fact_id`, `known_by` | Module requires prior knowledge to make sense. |
#### Player Conditions (Optional)
Player conditions are *optional* — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state.
#### Activation Triggers
| Trigger | When to Use |
|---------|-------------|
| `storyteller_push` | Default. Storyteller activates on its own pacing. Most Tier 1 modules. |
| `proximity` | Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. |
| `player_action` | Reserved for modules that require player initiation. Use rarely. |
**The `min_play_ticks` field is load-bearing for D-027 criterion #1.** At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set `min_play_ticks` to at least 1800. The vertical slice uses 2100 to give extra breathing room.
---
### NPC Requirements
Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document.
| Field | Required | Description |
|-------|----------|-------------|
| `role` | Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. |
| `display_hint` | No | Authoring note: who this role is narratively. |
| `binding` | Yes | `named` (specific authored NPC) or `generated` (any matching NPC). |
| `named_npc` | Conditional | Required when `binding: named`. Short-form canonical ID: `npc:{slug}`. |
| `axes` | Conditional | Required when `binding: generated`. Axis constraints the NPC must satisfy. |
| `must_have_pattern` | No | Optional NPC pattern (D-024 System A). |
| `must_have_motivation` | No | Optional NPC motivation (D-024 System B). |
| `is_optional` | No | Default false. If true, module runs without this slot filled (degraded experience). |
#### Named vs. Generated Bindings
**Named bindings** reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for:
- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior
- NPCs with unique relationships in the 5-triangle web
- Roles where voice, history, and moral weight matter
**Generated bindings** are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints:
```yaml
axes:
- axis: secret
constraint: has_major_secret
- axis: contentment
constraint: min_contentment_-3 # Discontented, susceptible to opportunity
```
Constraint conventions: `has_{value}`, `min_{N}`, `not_{value}`. The server's NPC filter system interprets these.
#### What "Roles" Are Not
NPC roles in a drama module are **not** the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are:
- Functional slots within the module's narrative (ring-leader, witness, evidence-holder)
- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module
- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug
#### NPC Pattern and Motivation Reference
Patterns (System A, `must_have_pattern`) encode the NPC's thematic function in the player's experience:
| Pattern | What It Means |
|---------|---------------|
| `FRIEND` | Emotionally complex anchor; the contradiction arc lives here (D-034) |
| `MIRROR` | Reflects the player character's own path back at them |
| `ANCHOR` | Reliable presence; stability the player can always return to |
| `GHOST` | Presence felt more than seen; past hangs over current events |
| `CATALYST` | Actions cause cascading effects on other NPCs |
| `THRESHOLD` | Gatekeeper; controls access to deeper information or relationships |
| `REMNANT` | Survivor of a prior event; carries knowledge others want buried |
| `SYSTEM` | Embodies an institution or faction rather than personal stakes |
| `NOBODY` | Genuinely flat; texture and atmosphere, no arc |
Motivations (System B, `must_have_motivation`) encode why the NPC acts within the module's conspiracy:
| Motivation | What It Means |
|------------|---------------|
| `HANDLER` | Organizes or directs others; the operational center |
| `WITNESS` | Knows something they haven't decided to act on |
| `TURNCOAT` | Wants out, or has already switched allegiance |
| `CIVILIAN` | No conspiracy involvement; proximity creates moral weight |
| `OPERATOR` | Executes tasks; functional cog in the system |
| `SKEPTIC` | Doubts the conspiracy exists; useful foil for investigation |
**Full definitions and canonical usage:** `decisions/content.md` D-024.
---
### Events
Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them.
#### Sequences vs. Pools
| Structure | Use For |
|-----------|---------|
| **Sequence** | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. |
| **Pool** | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. |
The vertical slice uses:
- `kael_exit_arc` (sequence) — Kael's ordered character arc
- `investigation_pressure` (sequence) — Parallel pressure escalation
- `ambient_ring_activity` (pool) — Background ring business that runs throughout
Most modules should have 1-2 sequences plus 1 pool.
#### Event Step Fields
| Field | Required | Description |
|-------|----------|-------------|
| `event_id` | Yes | Unique within module. Used in outcome conditions and `ticks_since_event` triggers. |
| `label` | No | Short human-readable label for dev tooling. |
| `description` | No | What happens narratively. Write this first — events should have a clear observable presence. |
| `triggers` | Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. |
| `effects` | No | What changes in the world. |
| `once` | No | Default `true`. Set `false` for repeating events (ambient discrepancies, etc.). |
| `sets_flag` | No | Module-internal flag set when event fires. Used in outcome conditions. |
#### Trigger Types
| Type | Fires When | Key Fields |
|------|-----------|------------|
| `ticks_since_activation` | N ticks after module activated | `ticks` |
| `ticks_since_event` | N ticks after a previous event fired | `after_event`, `ticks` |
| `player_proximity` | Player near NPC/location | `target_type`, `target`, `radius_tiles` |
| `player_action` | Player interacts with target | `action`, `target_role` |
| `fact_known_by_player` | Player has discovered a fact | `fact_id` |
| `flag_set` | A module flag has been set | `flag` |
| `npc_mood` | NPC enters a mood state | `npc_role`, `mood` |
**Design principle: events should fire without the player.** Every event must have at least one tick-based trigger (`ticks_since_activation` or `ticks_since_event`). Proximity and action triggers are secondary paths that fire the event *earlier* if the player engages. The world moves at its own pace; the player accelerates or delays, not controls.
#### Effect Types
| Type | Use For |
|------|---------|
| `npc_routine_deviation` | Visible NPC behavior change. Write this descriptively — it's what the player sees. |
| `fact_becomes_discoverable` | Gates a fact into the knowledge graph at Rumoured confidence. |
| `tell_intensify` | NPC's tell behavior becomes more frequent/pronounced. |
| `flag_set` | Internal state tracking. Not visible to player. |
| `location_state` | Something visible changes in a location. |
| `npc_knowledge_update` | An NPC learns something new. |
**On `fact_becomes_discoverable`:** This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The `discovery_method` field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog.
**Fact ID convention:** Use `{module-slug}.{fact_name}` — e.g., `ring.kael_unauthorized_corridor_access`. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check `content/global/knowledge/` to see if an equivalent fact already exists; reuse it rather than creating a duplicate.
**Mapping `discovery_method` to D-035 trigger types:** The `discovery_method` note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in `decisions/content.md` D-035 and `content/global/enums/triggers.yaml`):
| If discovery happens via… | D-035 trigger type | What to author |
|--------------------------|-------------------|----------------|
| Player enters the location where something is visible | `enter_location` | Monologue line flagging the anomaly on arrival |
| Player watches an NPC doing something unusual | `observe_npc` | Monologue line on NPC observation; dialogue option unlocks |
| Player examines an object or terminal | `observe_anomaly` | Examine verb interaction; monologue on result |
| Player witnesses two NPCs interacting | `witness_interaction` | Monologue line; trust-gated gossip unlock |
| Player finishes a conversation with the relevant NPC | `post_conversation` | Monologue beat after talking to the NPC |
| Player discovers a physical object (cargo, message) | `discover_evidence` | Examine verb; monologue on discovery |
| Player returns to a location they've been before | `return_visit` | Monologue on changed state vs. prior visit |
Write the `discovery_method` note to specify which of these applies — ideally two methods for redundancy (e.g., `enter_location` plus `observe_anomaly`) so players aren't funneled into a single approach.
---
### Outcomes
Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied.
**Every module must include:**
- At least one terminal outcome that represents "the investigation succeeded"
- At least one terminal outcome that represents "the conspiracy ran its course"
- Exactly one expiry outcome (`is_expiry: true`) for quiet player non-engagement
#### Outcome Fields
| Field | Required | Description |
|-------|----------|-------------|
| `outcome_id` | Yes | Unique slug. |
| `label` | Yes | Short label. |
| `is_terminal` | Yes | `true` = module ends. `false` = transitional state (module can continue evolving). |
| `is_expiry` | No | `true` = this is the quiet-exit outcome. One per module. |
| `conditions` | No | ALL conditions must be true. See below. |
| `effects` | No | World changes when outcome is reached. |
**On `is_terminal: false`:** A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the `ring_splinters` outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream.
#### Outcome Conditions
| Condition | Description |
|-----------|-------------|
| `facts_known` | Player must know all listed facts. |
| `facts_not_known` | Player must NOT know any listed facts. |
| `flags_set` | All listed module flags must be set. |
| `flags_not_set` | None of listed flags may be set. |
| `events_fired` | All listed events must have fired. |
| `ticks_since_activation` | Module has been running for at least N ticks. |
#### Outcome Effects
| Type | Description |
|------|-------------|
| `npc_disposition` | NPC's relationship state with player shifts. |
| `faction_reaction` | Faction reputation change. |
| `location_access_change` | Location becomes restricted, locked, or open. |
| `fact_state` | Fact is permanently known, hidden, or destroyed. |
| `npc_exit` | NPC leaves the district or becomes inaccessible. |
---
## Design Principles for Tier 1 Modules
### 1. The World Moves First
Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module.
### 2. Both Characters Must Have a Story
Every event and outcome must mean something different to the smuggler and the detective. Write the `dual_lens` authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1.
### 3. No Clean Resolutions
D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design.
### 4. THE FRIEND Contradiction Is the Pivot
If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be:
- **Observable from spatial positioning** — not from dialogue, not from menus
- **Ambiguous before context** — the player sees the behavior before they understand what it means
- **Irreversible once witnessed** — seeing changes the relationship, even if the player does nothing
The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw.
### 5. Expiry Is Not Failure
The `module_abandoned` expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive.
### 6. Facts, Not Flags, Drive Investigation
Facts (from `global/knowledge/`) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag.
Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact.
---
## Validation and Format Rules (Gestalt)
These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (`make validate-content`), but catching them during authoring saves a pipeline run.
### ID and Slug Formats
| Field | Regex | Example |
|-------|-------|---------|
| `module_id` | `^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$` | `smuggling_ring_v0_1` |
| `sequence_id`, `pool_id` | `^[a-z][a-z0-9_-]*$` | `kael_exit_arc` |
| `event_id` | `^[a-z][a-z0-9_-]*$` | `kael_goes_cold` |
| `outcome_id` | `^[a-z][a-z0-9_-]*$` | `ring_exposed` |
| `sets_flag` / flag references | `^[a-z][a-z0-9_-]*$` | `kael_behavior_changed` |
| `role` (npc slot) | `^[a-z][a-z0-9-]*$` | `ring-member-exiting` |
| `named_npc` | `^npc:[a-z][a-z0-9-]*$` | `npc:kael-davan` |
| `version` | `^[0-9]+\\.[0-9]+$` | `0.1` |
Note the difference: `event_id`, `outcome_id`, `sequence_id`, and flags use underscores and hyphens (`[a-z0-9_-]*`). NPC `role` slugs use hyphens only (`[a-z0-9-]*`). Mixing them in wrong fields will fail schema validation.
### Flag Naming Convention
Flags are module-internal state. Every flag name that appears in `sets_flag` on an event **must** also appear in at least one outcome's `flags_set` or `flags_not_set` condition — or the flag serves no purpose. Convention:
- Use `snake_case` with underscores: `kael_behavior_changed`, `voss_pressure_applied`
- Name by what happened, not what it enables: `handler_pressure_applied` not `kael_ready_to_flee`
- Flags set by events accumulate — they are never automatically cleared
- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below)
### Axis Constraint Syntax (Generated NPC Bindings)
The `constraint` field in `axes` is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce):
| Prefix | Example | Meaning |
|--------|---------|---------|
| `has_` | `has_major_secret` | NPC axis value includes this descriptor |
| `min_contentment_` | `min_contentment_-3` | Contentment axis value ≤ N (more discontented) |
| `not_` | `not_combat_trained` | Axis value does NOT include this descriptor |
| `is_` | `is_ring_member` | Boolean flag set on NPC profile |
### What JSON Schema Cannot Validate (Tier 2 Catches These)
| Issue | Where to Look | Impact |
|-------|--------------|--------|
| `fact_id` not defined in `global/knowledge/` | Effect `fact_becomes_discoverable`, outcome `facts_known` | Fact silently never becomes discoverable |
| `sets_flag` name not referenced in any outcome condition | Event `sets_flag` | Flag is set but never meaningful |
| `flags_set`/`flags_not_set` reference flag never set by any event | Outcome conditions | Condition permanently true or false |
| `ticks_since_event.after_event` references unknown event_id | Event trigger | Trigger never fires |
| `named_npc` ID doesn't exist in district NPC profiles | NPC requirements | Load-time failure |
| Multiple outcomes have `is_expiry: true` | Outcomes list | Undefined storyteller behavior |
| `faction` in outcome effects not in `global/factions/` | Outcome effects | Effect silently ignored |
### The Expiry Condition Pitfall
This is the most common authoring mistake for expiry outcomes. **The expiry condition must use `facts_not_known`, not `flags_not_set`.** Reason:
Events with `ticks_since_activation` triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks `flags_not_set: [that_flag]`, the expiry condition becomes permanently false after the event fires — the module can never expire quietly.
**Wrong:**
```yaml
# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed
# This expiry can never fire after tick 300
- outcome_id: module_abandoned
is_expiry: true
conditions:
flags_not_set:
- kael_behavior_changed # This flag is always set by tick 300
ticks_since_activation: 5400
```
**Correct:**
```yaml
# facts_not_known gates on player investigative action, not auto-fired events
- outcome_id: module_abandoned
is_expiry: true
conditions:
facts_not_known:
- "ring.cargo_discrepancy_pattern" # Only known if player examined terminal
- "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael
ticks_since_activation: 5400
```
---
## Checklist Before Submitting a New Module
- [ ] `module_id` uses correct format and doesn't collide with existing modules
- [ ] `dual_lens` is written and shows clearly different experiences per character
- [ ] `min_play_ticks` ≥ 1800 (30 minutes at 1 tick/second)
- [ ] Every event sequence step has at least one tick-based trigger
- [ ] Every `fact_becomes_discoverable` effect has a `discovery_method` note
- [ ] The module includes at least one named FRIEND-pattern NPC (for v0.1 modules)
- [ ] Expiry outcome is present (`is_expiry: true`) with conditions gated on `facts_not_known`, NOT `flags_not_set`
- [ ] All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution
- [ ] `npc_requirements` covers every role referenced in events and outcomes
- [ ] All fact IDs used in effects/conditions exist in `global/knowledge/`
- [ ] All `sets_flag` names appear in at least one outcome condition
- [ ] All `flags_set`/`flags_not_set` names are set by at least one event's `sets_flag`
- [ ] `make validate-content` passes
---
## Cross-References
| Topic | Location |
|-------|----------|
| Three-tier content model | `decisions/content.md` D-023 |
| NPC 10-axis model | `decisions/content.md` D-024 |
| Vertical slice scope | `decisions/scope.md` D-027 |
| Population ratios | `decisions/content.md` D-029 |
| THE FRIEND pattern | `decisions/content.md` D-034 |
| Smuggling ring module | `content/modules/tier1/smuggling_ring_v0_1.yaml` |
| Drama module schema | `content/schemas/drama_module.schema.yaml` |
| Fact catalog | `content/global/knowledge/` |
| NPC profiles (v0.1) | `content/campaigns/main/systems/krenn/` |
| Storyteller stub | `server/src/storyteller/mod.rs` |
---
*Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).*
+1 -1
View File
@@ -60,7 +60,7 @@ Main menu, pause, save/load, options.
|-----------|---------|-------------------|
| [v01-main-menu](menus/v01-main-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (functional warmth style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (two-character proof — character select) |
| [v01-pause-menu](menus/v01-pause-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-085](../../../decisions/architecture.md#d-085-per-game-save-directory-structure) (per-game save dirs), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice) |
| [v10-main-menu](menus/v10-main-menu.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-036](../../../decisions/content.md#d-036-sova-transit-district--krenn-system-as-v01-setting) (setting — Sova Transit District), [D-013](../../../decisions/scope.md#d-013-diegetic-insertpoi-navigation-system) (diegetic insert — in-fiction menu) |
| [v10-options-full](menus/v10-options-full.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-068](../../../decisions/architecture.md#d-068-5-bus-audio-architecture) (5-bus audio — per-bus volume controls), [D-069](../../../decisions/perception.md#d-069-audio-dip-profiles-for-dialogue-and-confrontation) (audio dip profiles) |
+136 -72
View File
@@ -37,156 +37,220 @@
"tab-save": {
"type": "Rectangle",
"left": 140, "top": 116, "width": 120, "height": 32,
"fillColor": "#1a2030",
"strokeColor": "#c8d0e0",
"fillColor": "#0d1018",
"strokeColor": "#333340",
"corners": [2, 2, 0, 0]
},
"tab-save-text": {
"type": "Text",
"left": 156, "top": 125,
"text": "SAVE",
"fontColor": "#c8d0e0",
"fontColor": "#556677",
"fontSize": 13
},
"tab-load": {
"type": "Rectangle",
"left": 264, "top": 116, "width": 120, "height": 32,
"fillColor": "#0d1018",
"strokeColor": "#333340",
"fillColor": "#1a2030",
"strokeColor": "#c8d0e0",
"corners": [2, 2, 0, 0]
},
"tab-load-text": {
"type": "Text",
"left": 280, "top": 125,
"text": "LOAD",
"fontColor": "#556677",
"fontColor": "#c8d0e0",
"fontSize": 13
},
"save-slot-1-active": {
"game-1-header": {
"type": "Rectangle",
"left": 140, "top": 156, "width": 860, "height": 72,
"left": 140, "top": 156, "width": 860, "height": 32,
"fillColor": "#151a24",
"strokeColor": "#333340",
"corners": [2, 2, 0, 0]
},
"game-1-title": {
"type": "Text",
"left": 152, "top": 165,
"text": "\u25bc DETECTIVE \u2014 Day 3 // Sova Transit // last played: today",
"fontColor": "#c8d0e0",
"fontSize": 12
},
"game-1-count": {
"type": "Text",
"left": 920, "top": 165,
"text": "3 saves",
"fontColor": "#556677",
"fontSize": 11
},
"qs-row": {
"type": "Rectangle",
"left": 158, "top": 192, "width": 842, "height": 62,
"fillColor": "#1a2030",
"strokeColor": "#c8d8f0",
"corners": [2, 2, 2, 2]
},
"save-slot-1-accent": {
"qs-accent": {
"type": "Rectangle",
"left": 140, "top": 156, "width": 3, "height": 72,
"left": 158, "top": 192, "width": 3, "height": 62,
"fillColor": "#c8d8f0",
"strokeColor": "#c8d8f0"
},
"slot-1-date": {
"qs-label": {
"type": "Text",
"left": 152, "top": 162,
"text": "AUTOSAVE // Day 1, 07:42",
"left": 170, "top": 200,
"text": "QUICKSAVE // Day 3, 14:22",
"fontColor": "#c8d0e0",
"fontSize": 13
},
"qs-location": {
"type": "Text",
"left": 170, "top": 218,
"text": "The Terminal \u2014 afternoon shift",
"fontColor": "#8899aa",
"fontSize": 12
},
"qs-timestamp": {
"type": "Text",
"left": 170, "top": 234,
"text": "saved: 2026-02-25 16:31",
"fontColor": "#556677",
"fontSize": 11
},
"qs-actions": {
"type": "Text",
"left": 840, "top": 212,
"text": "[Enter] Load\n[F6] Quickload",
"fontColor": "#c8d8f0",
"fontSize": 11
},
"auto-row": {
"type": "Rectangle",
"left": 158, "top": 260, "width": 842, "height": 62,
"fillColor": "#0e1118",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"auto-label": {
"type": "Text",
"left": 170, "top": 268,
"text": "AUTOSAVE // Day 3, 13:45",
"fontColor": "#8899aa",
"fontSize": 13
},
"auto-location": {
"type": "Text",
"left": 170, "top": 286,
"text": "Corridor B-7",
"fontColor": "#556677",
"fontSize": 12
},
"auto-timestamp": {
"type": "Text",
"left": 170, "top": 302,
"text": "saved: 2026-02-25 16:15",
"fontColor": "#3a4455",
"fontSize": 11
},
"slot-1-row": {
"type": "Rectangle",
"left": 158, "top": 328, "width": 842, "height": 62,
"fillColor": "#0e1118",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"slot-1-label": {
"type": "Text",
"left": 170, "top": 336,
"text": "SLOT 1 // Day 2, 22:10",
"fontColor": "#8899aa",
"fontSize": 13
},
"slot-1-location": {
"type": "Text",
"left": 152, "top": 180,
"text": "The Terminal — morning shift // Detective",
"fontColor": "#8899aa",
"left": 170, "top": 354,
"text": "Hab quarters \u2014 evening",
"fontColor": "#556677",
"fontSize": 12
},
"slot-1-timestamp": {
"type": "Text",
"left": 152, "top": 198,
"text": "saved: 2026-02-23 14:31",
"fontColor": "#556677",
"left": 170, "top": 370,
"text": "saved: 2026-02-25 14:48",
"fontColor": "#3a4455",
"fontSize": 11
},
"slot-1-actions": {
"type": "Text",
"left": 860, "top": 175,
"text": "[Enter] Overwrite / Load",
"fontColor": "#c8d8f0",
"fontSize": 12
},
"save-slot-2": {
"game-2-header": {
"type": "Rectangle",
"left": 140, "top": 236, "width": 860, "height": 72,
"fillColor": "#0e1118",
"left": 140, "top": 404, "width": 860, "height": 32,
"fillColor": "#111520",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"slot-2-date": {
"game-2-title": {
"type": "Text",
"left": 152, "top": 250,
"text": "SLOT 2 // Day 1, 06:15",
"left": 152, "top": 413,
"text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday",
"fontColor": "#8899aa",
"fontSize": 13
},
"slot-2-location": {
"type": "Text",
"left": 152, "top": 268,
"text": "Arrival — entering Sova Transit // Detective",
"fontColor": "#556677",
"fontSize": 12
},
"slot-2-timestamp": {
"game-2-count": {
"type": "Text",
"left": 152, "top": 286,
"text": "saved: 2026-02-23 13:10",
"left": 920, "top": 413,
"text": "2 saves",
"fontColor": "#3a4455",
"fontSize": 11
},
"save-slot-3": {
"game-3-header": {
"type": "Rectangle",
"left": 140, "top": 316, "width": 860, "height": 72,
"fillColor": "#0e1118",
"left": 140, "top": 444, "width": 860, "height": 32,
"fillColor": "#111520",
"strokeColor": "#333340",
"corners": [2, 2, 2, 2]
},
"slot-3-date": {
"game-3-title": {
"type": "Text",
"left": 152, "top": 330,
"text": "SLOT 3 // Day 1, 07:30",
"left": 152, "top": 453,
"text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20",
"fontColor": "#8899aa",
"fontSize": 13
},
"slot-3-location": {
"type": "Text",
"left": 152, "top": 348,
"text": "Corridor B-7 — Kael spotted // Smuggler",
"fontColor": "#556677",
"fontSize": 12
},
"slot-3-timestamp": {
"game-3-count": {
"type": "Text",
"left": 152, "top": 366,
"text": "saved: 2026-02-23 12:48",
"left": 920, "top": 453,
"text": "5 saves",
"fontColor": "#3a4455",
"fontSize": 11
},
"empty-slots-label": {
"footer-note": {
"type": "Text",
"left": 140, "top": 400,
"text": "SLOTS 4-8 — empty",
"fontColor": "#2a3040",
"fontSize": 12
},
"save-note": {
"type": "Text",
"left": 140, "top": 660,
"text": "Autosave on: zone transitions, conversation ends, significant events",
"left": 140, "top": 660, "width": 860,
"text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload",
"fontColor": "#3a4455",
"fontSize": 11
"fontSize": 11,
"wordWrap": true
},
"annotation-title": {
"type": "Text",
"left": 16, "top": 720,
"text": "v0.1 SAVE / LOAD SCREEN",
"text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)",
"fontColor": "#556677",
"fontSize": 11
},
"annotation-notes": {
"type": "Text",
"left": 16, "top": 734, "width": 1100,
"text": "Tab UI: Save / Load. Slots show: timestamp, in-game time+location, character. Autosave shown separately. Active slot has accent bar. No screenshots in v0.1.",
"text": "Games grouped by directory (D-085). Expand to see saves. QUICKSAVE + AUTOSAVE are system slots; manual slots below. SAVE tab shows current game only. F5/F6 global hotkeys for quicksave/quickload.",
"fontColor": "#3a4455",
"fontSize": 10,
"wordWrap": true
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+122
View File
@@ -0,0 +1,122 @@
# Sprint 19: Persist — CI Tasks
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
**Branch:** `ci`
**Agents:** Hoshe (QA/CI), Oscar (networking)
## Carry-over from Sprint 18
None.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #270 | Test runner bash scripts | — |
| #556 | Protocol version handshake: client | #555 (server) |
| #342 | IPC round-trip timing benchmark | #555, #556 |
| #271 | IPC serialization fixture files | #270 |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack codec, SimBridge trait), D-030 (three-layer test architecture: fixture / mock-protocol / real-subprocess)
## Notes
### #270 — Test runner bash scripts
The test infrastructure has no standardized entry points for CI or agents to invoke. This ticket ships the runner layer.
What this ticket must deliver:
Six scripts at `tests/`:
1. `tests/run-rust` — runs `cargo test` in `server/`, exits 0/non-zero, JSON stdout summary
2. `tests/run-godot` — runs GUT headlessly (`godot --headless -s client/tests/run_gut.gd`), exits 0/non-zero
3. `tests/run-ipc-fixtures` — Layer 1: reads fixture files from `tests/fixtures/`, validates via Rust + GDScript, exits 0/non-zero
4. `tests/run-ipc-protocol` — Layer 2: runs mock subprocess protocol state machine tests
5. `tests/run-ipc-integration` — Layer 3: starts real server subprocess, runs full round-trip, kills it
6. `tests/run-all` — invokes all five in order, collects exit codes, reports JSON summary
Script requirements per ticket description: exit code 0/non-zero, structured JSON stdout, accepts filter arguments (`--filter test_name`), no interactive input, whitelistable for Claude Code agents (no TTY prompts).
JSON stdout format (consistent across all scripts):
```json
{"suite": "rust", "total": 42, "passed": 42, "failed": 0, "duration_ms": 1230}
```
These scripts are the entry points that `make ci-server`, `make ci-client`, and future CI pipelines call. Coordinate with Makefile targets in `docs/DEVOPS.md`.
### #556 — Protocol version handshake: client
Blocked by #555 (server must send `HandshakeMessage` first).
What this ticket must deliver:
- `client/scripts/protocol/local_bridge.gd` (or `server_process.gd`): after starting the server subprocess, read the first framed message from the IPC channel
- Validate it is a `HandshakeMessage` with `protocol_version == Protocol.PROTOCOL_VERSION` (14)
- If mismatch: log error "Protocol version mismatch: server=%d, client=%d", emit a `handshake_failed` signal, shut down the server process gracefully
- If match: emit `handshake_complete`, begin normal tick loop
- Add a timeout: if no handshake message received within 5 seconds of process start, treat as mismatch
Current state: `client/scripts/protocol/protocol.gd` already checks `version` in `decode_snapshot()` and logs a mismatch. That check is per-snapshot. The handshake is the startup-time equivalent — validate once at connection, not per tick.
Files: `client/scripts/protocol/local_bridge.gd`, `client/scripts/protocol/server_process.gd`.
### #342 — IPC round-trip timing benchmark
Sprint exit criterion. Measures the complete latency path from server serialization to client scene update.
What this ticket must deliver:
- A benchmark script `tests/run-ipc-benchmark` that:
1. Starts the server subprocess
2. Waits for handshake (#555/#556)
3. Sends N `PlayerInput` messages (N = 100 by default)
4. Measures from `rmp_serde::to_vec` (server) to scene update completion (client)
5. Reports p50/p95/p99 latencies in milliseconds
6. Flags if any percentile exceeds 5ms threshold
- Output JSON: `{"p50_ms": 1.2, "p95_ms": 2.8, "p99_ms": 4.1, "threshold_ms": 5, "passed": true}`
- The benchmark is run as part of `tests/run-ipc-integration` in Layer 3
Implementation approach: server-side timestamps in `ObserverSnapshot` (add `server_emit_tick_ms` field, stripped in production builds), client records receive timestamp via `Time.get_ticks_msec()`. Delta = client receive - server emit.
Blocked by #555 and #556 — benchmark requires a working handshake before timing can start cleanly.
### #271 — IPC serialization fixture files
Layer 1 test data: pre-generated `.msgpack` fixture files that both Rust and GDScript can read to verify cross-language serialization compatibility.
What this ticket must deliver:
- A Rust binary (or test in `server/src/`) that generates fixtures to `tests/fixtures/`:
- `snapshot_minimal.msgpack` — minimal valid `ObserverSnapshot` (version=14, tick=0, one entity)
- `snapshot_full.msgpack` — all optional fields populated (monologue, dialogue, inventory, POIs, KG dump)
- `player_input_move.msgpack` — `PlayerInput { tick: 1, action: MoveNorth }`
- `player_input_interact.msgpack` — `PlayerInput { tick: 2, action: Interact { target: 99, verb: "Talk" } }`
- `malformed.msgpack` — intentionally truncated bytes (tests error handling)
- A GDScript test `client/tests/test_ipc_fixtures.gd` that reads each `.msgpack` fixture file, decodes via `Protocol.decode_snapshot()` / `Protocol.decode_player_input()`, and asserts expected field values
- Cross-language verification: the same byte stream decoded by both Rust and GDScript must produce identical field values
The fixture generator is run once (manually or in CI pre-step) to produce the committed `.msgpack` files. The files live at `tests/fixtures/` and are committed to the repo.
Blocked by #270 — fixture tests are invoked by `tests/run-ipc-fixtures`.
## Dependency Chain
```
#555 (server: protocol handshake) → #556 (ci: protocol handshake: client)
#555 + #556 → #342 (IPC benchmark: requires working handshake)
#270 (test runner scripts) → #271 (fixture files: invoked by run-ipc-fixtures)
Parallel starts: #270, #555 (server-side) — both unblocked week 1
#556 starts after #555 is at review
#271 starts after #270 merges
#342 starts after #555 + #556 both land
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): description" --description "body" --base main --head ci
```
+128
View File
@@ -0,0 +1,128 @@
# Sprint 19: Persist — Client Tasks
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
**Branch:** `client`
**Agents:** Stig (UI), Oscar (networking)
## Carry-over from Sprint 18
None. Sprint 18 closed clean.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #554 | Save/load: client UI | #553 (server) |
| #258 | Game session management | — |
| #205 | GDScript test framework setup | — |
| #206 | Scene testing utilities | #205 |
| #348 | Debug visualization overlay | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack), D-085 (per-game save directory structure)
- `decisions/questions.md` — Q-029 (save file format design — open, Sprint 19 uses MessagePack quick-and-dirty format)
## Open Questions to Resolve Early
- **Q-029: Save file format design** — Sprint 19 ships MessagePack quick-and-dirty format. Do not over-engineer the loading screen metadata. A readable directory name (`<timestamp>-<seed>/`) per D-085 is sufficient for v0.1. The full versioning/migration design is tracked in Q-029 for a later sprint.
## Notes
### #554 — Save/load: client UI
Blocked by #553 (server must implement `SaveCommand`/`LoadCommand` IPC messages before client can wire F5/F6).
What this ticket must deliver:
- F5 key mapped in `client/scripts/autoloads/input_mapper.gd` to send a `SaveGame` IPC action to the server with the active game directory path (`user://saves/<game-id>/quicksave.sav`)
- F6 key mapped to send `LoadGame` IPC action with the same path
- Server responds with `SaveComplete`/`LoadComplete` — client shows a brief HUD notification ("Saved" / "Loading...")
- Loading screen scene: reads `user://saves/` directory, lists subdirectories sorted by last-modified (most recent first), shows most recent save filename per game directory per D-085
- F6 from the main menu opens the loading screen
- The active `game-id` is tracked in `GameState` autoload (add `current_game_id: String`)
Integration points: `client/scripts/autoloads/input_mapper.gd` (key bindings), `client/scripts/autoloads/game_state.gd` (current_game_id field), `client/scripts/protocol/` (new IPC message encoding), `client/scripts/ui/` (loading screen scene).
Save directory path per D-085: `user://saves/<timestamp>-<seed>/` where game-id is created on New Game (#258). F5 quicksave writes to `user://saves/<game-id>/quicksave.sav`. Loading screen lists directories sorted by `FileAccess.get_modified_time()`.
Wireframe reference: `docs/design/wireframes/menus/v01-save-load.png`.
### #258 — Game session management
New Game creates the per-game save directory before any save occurs (D-085 requirement: "directory created on New Game — even before the first save, so the path exists for quicksave/autosave").
What this ticket must deliver:
- `GameState.current_game_id: String` — format `<timestamp>-<seed>` (e.g. `20260225-143022-a7b3f1`)
- On "New Game": generate game-id (timestamp + RNG hex suffix), create `user://saves/<game-id>/` directory via `DirAccess.make_dir_recursive()`
- On "Continue" / loading screen selection: set `current_game_id` from the selected directory name
- "Quit to menu" flow: prompt "Save before quitting?" — F5 save if confirmed
- Wire the game-id into the `SimBridge` startup: server subprocess launched with `--game-id <id>` argument (or equivalent) so server can log with the same ID
Integration points: `client/scripts/autoloads/game_state.gd` (new fields), `client/scripts/protocol/server_process.gd` (subprocess launch args), `client/scripts/ui/` (main menu scene: New Game / Continue buttons).
Note: `game_state.gd` is already the largest autoload with 300+ lines. Keep game session logic in a thin wrapper on `GameState` — do not add another 100-line block directly. Consider a `session_manager.gd` helper if the logic exceeds 40 lines.
### #205 — GDScript test framework setup
The project has no GDScript test infrastructure yet. The Godot client has no equivalent of `cargo test`.
What this ticket must deliver:
- Install and configure **GUT (Godot Unit Test)** as the GDScript test framework — it has the best Godot 4 support and is actively maintained
- Create `client/tests/` as the test root directory
- `client/tests/run_gut.gd`: the GUT runner script that CI can invoke headlessly (`godot --headless -s client/tests/run_gut.gd`)
- Exit code 0 = all pass, non-zero = failures — required for CI integration (#270 test runner scripts)
- A single smoke test `client/tests/test_protocol.gd`: verifies `Protocol.decode_snapshot(bytes)` returns non-null for a minimal valid msgpack fixture
GUT installation: add as a Godot addon. Check if there is already an `addons/` directory in `client/`.
### #206 — Scene testing utilities
Blocked by #205 (GUT must be installed first).
What this ticket must deliver:
- `client/tests/util/scene_helper.gd`: loads a scene file by path, instantiates it into a temporary viewport, provides `assert_node_exists(path)`, `assert_signal_emitted(node, signal_name)`, and `get_node_at(path)` helpers
- `client/tests/test_game_state.gd`: tests for `GameState.apply_snapshot()` — verify that a snapshot dictionary with known fields updates the correct `GameState` fields
- `client/tests/test_protocol.gd` (extend from #205 smoke test): add roundtrip test for `Protocol.encode_player_input()` and `Protocol.decode_player_input()`
These utilities are the scaffolding for all future client tests. Keep them minimal and dependency-free — do not require a running server.
### #348 — Debug visualization overlay
Dev tool (F3 toggle). The stub `client/scripts/ui/debug_overlay.gd` already exists.
What this ticket must deliver:
- Extend `debug_overlay.gd` to draw on a `CanvasLayer` above the game world:
- **Pathfinding waypoints**: draw lines between waypoint positions from `GameState.visible_entities` (entities with kind `Npc` — estimate waypoints from position delta between ticks)
- **Line-of-sight rays**: draw lines from player position to each visible entity
- **Vision cone boundary**: draw the forward/peripheral arc boundary using `GameState.visibility_sectors`
- **Information state tags**: draw confidence label (Suspects/KnowsOf/KnowsDetails/Direct) above each visible NPC from `GameState.player_knowledge`
- **Tick timing graph**: small line chart in corner showing tick delta over the last 30 ticks
- F3 toggle: connected to `InputMapper` action `toggle_debug_overlay`
- Debug overlay is **dev-only**: compiled out in export builds via `OS.is_debug_build()` check
Integration points: `client/scripts/autoloads/game_state.gd` (data source), `client/scripts/autoloads/input_mapper.gd` (F3 action), `client/scripts/ui/debug_overlay.gd` (extend existing stub).
## Dependency Chain
```
#205 (GDScript test framework) → #206 (scene testing utilities)
#258 (game session management) → #554 (save/load client UI: needs current_game_id)
#553 (server, ECS extraction) → #554 (save/load client UI: needs IPC commands)
#348 (debug overlay) → standalone, parallel track
```
Parallel starts: #258, #205, #348 all unblocked week 1.
#554 starts after #553 (server) reaches review stage and #258 lands.
#206 starts after #205 merges.
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
```
+97
View File
@@ -0,0 +1,97 @@
# Sprint 19: Persist — Joint Briefing
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
**Sprint:** 19
**Status:** planning → active
## Pre-Sprint
Before implementation begins, no schema work is needed — `SaveStateV1` is already defined (#256, done). However, the following IPC protocol additions must be agreed between server and client **before either side implements**:
| Item | Owner | Needed by |
|------|-------|-----------|
| `HandshakeMessage` wire format | server (#555) | client (#556) |
| `SaveCommand` / `LoadCommand` IPC message variants | server (#553) | client (#554) |
| `SaveComplete` / `LoadComplete` response format | server (#553) | client (#554) |
| Fixture file format and field names | ci (#271) | all teams |
Server team: define these in `server/src/bridge/types.rs` first (as Rust structs + serde). CI team + client team: implement against the published definitions. Do not start #556 or #554 until #555 and #553 respectively reach review.
## Team Allocation
| Team | Tickets | Count |
|------|---------|-------|
| server | #553, #96, #97, #98, #200, #272, #555 | 7 |
| client | #554, #258, #205, #206, #348 | 5 |
| ci | #270, #556, #342, #271 | 4 |
## Cross-Team Dependencies
```
server #555 (handshake: server)
→ ci #556 (handshake: client)
→ ci #342 (IPC benchmark)
server #553 (ECS extraction)
→ client #554 (save/load UI)
server #200 (test module org)
→ server #272 (info boundary tests)
client #205 (GDScript test framework)
→ client #206 (scene testing utilities)
→ ci #271 (fixture files need GDScript reader)
ci #270 (test runner scripts)
→ ci #271 (fixture tests invoked by run-ipc-fixtures)
```
## Sprint Completion Proof
When Sprint 19 is done, the following must all be observable:
1. **Save/load round-trip**: Press F5 in-game → file appears at `user://saves/<game-id>/quicksave.sav` in MessagePack format. Press F6 → game state restored from file (tick, entities, player knowledge match pre-save state).
2. **Per-game directory**: Starting a New Game creates `user://saves/<timestamp>-<seed>/` before any save occurs. The loading screen lists this directory.
3. **Tier eviction**: Spawn 90+ NPCs (above Active cap of 80). `ActiveSim` count stabilizes at ≤80 with the excess evicted to `BackgroundSim`/`StateSaved`. Scope-tagged NPCs (KnownContact, Colleague) remain Active regardless.
4. **Protocol handshake**: Starting the server subprocess: first IPC message is a `HandshakeMessage`. Version mismatch (force by temporarily changing server `PROTOCOL_VERSION`) produces an error and clean shutdown — no crash.
5. **Test infrastructure**: `tests/run-all` exits 0 with all suites passing. `tests/run-ipc-fixtures` reads committed `.msgpack` files and validates both Rust and GDScript decode them identically. `cargo test` in `server/` includes information boundary negative tests that assert absence of leakage.
6. **Debug overlay**: F3 in-game toggles the debug canvas showing vision cone arcs, entity LOS rays, NPC knowledge confidence labels, and tick timing graph.
## Test Plan (D-030)
| Layer | Runner | Tickets | When |
|-------|--------|---------|------|
| Layer 1: Fixture serialization | `tests/run-ipc-fixtures` | #271, #200 | Every edit |
| Layer 1: Unit tests (Rust) | `tests/run-rust` | #272, #96, #97, #98 | Every edit |
| Layer 1: Unit tests (GDScript) | `tests/run-godot` | #205, #206 | Every edit |
| Layer 2: Mock protocol | `tests/run-ipc-protocol` | #555, #556 | Every PR |
| Layer 3: Real subprocess | `tests/run-ipc-integration` | #342, #553/#554 | Daily/pre-merge |
All layers must pass before any PR merges. `make ci` invokes `tests/run-all`.
## Key Decisions Reference
| Decision | Domain file | Relevant to |
|----------|------------|-------------|
| D-010: Determinism + info boundaries | architecture.md | #272, #96, #553 |
| D-020: IPC architecture, MessagePack | architecture.md | #553, #554, #555, #556, #342, #271 |
| D-026: Simulation tiers, timestamp eviction, scope tags | architecture.md | #96, #97, #98 |
| D-030: Three-layer test architecture | architecture.md | #200, #270, #271, #272, #342 |
| D-085: Per-game save directory structure | architecture.md | #554, #258, #553 |
| Q-029: Save file format (open) | questions.md | #553 (quick-and-dirty MessagePack for now) |
## Risk Register
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| ECS extraction misses components (#553) | Medium | High | #272 info boundary tests catch leakage; fixture roundtrip (#271) catches missing fields |
| IPC protocol mismatch between #555 and #556 | Low | High | Define wire types in Rust first, share definition doc before client implements |
| GUT framework incompatible with Godot 4.x version in use (#205) | Low | Medium | Verify GUT version before full installation; fallback to hand-rolled test runner |
| Save file bloat (SaveStateV1 larger than ~1-2 KB/NPC) | Low | Low | Q-029 tracks compression — deferred. Profile with #342 benchmark if flagged |
| Scope tag assignment races with eviction (#97/#98) | Low | Medium | Eviction runs after scope tag system in schedule order; schedule ordering test in #97 |
+152
View File
@@ -0,0 +1,152 @@
# Sprint 19: Persist — Server Tasks
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
## Carry-over from Sprint 18
None. Sprint 18 closed clean.
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #553 | Save/load: server ECS extraction | #256 (done) |
| #96 | State serialization system | — |
| #97 | Timestamp-based eviction | — |
| #98 | Scope tag system | — |
| #200 | Test module organization | — |
| #272 | Information boundary negative test suite | #200 |
| #555 | Protocol version handshake: server | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/architecture.md` — D-010 (determinism + info boundaries), D-020 (IPC architecture, MessagePack), D-026 (simulation tiers: Active/Background/State-saved/Ungenerated, timestamp eviction, scope tags), D-030 (three-layer test architecture), D-041 (knowledge graph data model, StableId)
- `decisions/questions.md` — Q-029 (save file format design — Sprint 19 ships quick-and-dirty, Q-029 tracks the thorough design pass for later)
## Open Questions to Resolve Early
- **Q-029: Save file format design** — Sprint 19 uses MessagePack from `SaveStateV1`. Resolution of full versioning/migration strategy is deferred. Do not block #553 on Q-029; proceed with MessagePack format as specified.
## Notes
### #553 — Save/load: server ECS extraction
`server/src/simulation/save_state.rs` already defines `SaveStateV1` (done in #256). The data model is complete: tick, seed, RNG, `player_knowledge: KnowledgeGraph`, `relationship_graph: RelationshipGraph`, `npc_states: Vec<NpcSaveState>`. Roundtrip tests pass.
What this ticket must deliver:
- A `save_to_file(path: &Path, world: &World) -> Result<()>` function: queries ECS for all relevant components, builds a `SaveStateV1`, calls `state.to_bytes()`, writes to disk. Per-game directory path is provided by the client via a new `IpcCommand::SaveGame { path: String }` variant.
- A `load_from_file(path: &Path, world: &mut World) -> Result<()>` function: reads bytes, calls `SaveStateV1::from_bytes`, re-spawns entities, injects `KnowledgeGraph`, `RelationshipGraph`, and `SimulationTime` as resources, reseeds the RNG.
- A `SaveCommand` and `LoadCommand` IPC message pair wired through `server/src/bridge/` — server receives save/load triggers from the client, executes, sends `SaveComplete`/`LoadComplete` response.
- Format version check on load: reject files with `format_version != SAVE_FORMAT_VERSION` with a clear error.
Integration points: `server/src/simulation/save_state.rs` (data model), `server/src/bridge/types.rs` (new IPC commands), `server/src/bridge/local.rs` or `tcp.rs` (command dispatch), `server/src/knowledge/graph.rs` (KG re-injection), `server/src/npc/relationships.rs` (RelationshipGraph re-injection).
Gotcha: ECS entity IDs are generational — do not save bevy `Entity` handles. `SaveStateV1` already uses `StableId(u64)` throughout. On load, re-spawn entities and re-register `StableId -> Entity` in `EntityRegistry`.
### #96 — State serialization system
Complement to #553. Where #553 handles whole-game ECS extraction, #96 implements the per-NPC serialization primitive for tier transitions.
What this ticket must deliver:
- A `serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState` function producing the frozen struct (~1-2 KB per NPC per D-026)
- A `deserialize_npc_from_frozen(state: &NpcSaveState, commands: &mut Commands)` that re-spawns a full NPC entity with the correct component set
- Used by the tier system when evicting to `StateSaved`: instead of keeping ECS components live, serialize to `NpcSaveState` and despawn. On reactivation: deserialize and re-spawn.
- Unit tests: serialize + deserialize produces an entity with identical component values
Existing shape: `NpcSaveState` in `save_state.rs` captures position, `SecretSeverity`, `Relationships`, stress, tolerance, contentment, and optional `KnowledgeGraph`. Verify this covers all components needed for Background/Active reconstruction. Flag any missing axis (D-024) in a code comment for follow-up.
### #97 — Timestamp-based eviction
`server/src/simulation/tier.rs` has the tier marker components (`ActiveSim`, `BackgroundSim`, `StateSaved`) and the distance-based `update_tier_markers` system. What is missing: the LRU eviction when sim-space fills up.
What this ticket must deliver:
- A `LastInteractionTick(u64)` component on all NPCs, updated whenever the player interacts with or observes an NPC
- A `SimSpacePressure` resource tracking current `ActiveSim` count vs. capacity (cap: 80 per D-026)
- An `evict_excess_active` system: when `ActiveSim` count exceeds capacity, demote the N oldest-by-`LastInteractionTick` entities to `BackgroundSim` (or `StateSaved` if beyond background radius)
- Uses a priority queue (BinaryHeap keyed by `LastInteractionTick`) for O(log N) eviction selection
Gotcha: eviction must not demote entities with active scope tags (see #98). The eviction system runs after #98's `ScopeTag` check.
### #98 — Scope tag system
Scope tags are the mechanism by which certain NPCs stay pinned to `ActiveSim` regardless of distance or LRU pressure (D-026: "neighborhood, active-quest, colleague, known-contact").
What this ticket must deliver:
- A `ScopeTag` component (or enum-tagged component) with variants: `Neighborhood`, `ActiveQuest`, `Colleague`, `KnownContact`
- A `ScopePinned` marker component: attached to any NPC carrying a `ScopeTag`, removed when no scope tags remain
- The eviction system (#97) skips entities with `ScopePinned`
- Scope tags are assigned by gameplay systems: `Neighborhood` from proximity at session start, `KnownContact` from `KnowledgeGraph` entries with confidence >= `KnowsOf`, `Colleague` from `RelationshipGraph` edges with `Friend` or `Colleague` kind, `ActiveQuest` reserved for future quest system
Integration: `server/src/simulation/tier.rs` (eviction exclusion), `server/src/knowledge/graph.rs` (KnownContact assignment trigger), `server/src/npc/relationships.rs` (Colleague assignment trigger).
### #200 — Test module organization
`server/src/test_world/` already exists with `constants.rs`, `invariants.rs`, `mod.rs`, `reset.rs`, and `rooms/`. This is the foundation.
What this ticket must deliver:
- Establish the external test module pattern for the server crate: `#[cfg(test)] mod tests` in each module, plus a top-level `tests/` directory alongside `src/` for integration tests that run against the full simulation
- Document the three-layer test architecture (D-030): Layer 1 = fixture-based serialization (fast), Layer 2 = mock subprocess protocol state machine (medium), Layer 3 = real subprocess integration (slow)
- Create `tests/integration/mod.rs` as the entry point for Layer 3 tests
- Ensure `cargo test` in `server/` runs all layers correctly
- No-ops are fine for Layer 2 and 3 stubs — the important deliverable is the directory structure and entry points
#272 is blocked by this ticket — the information boundary tests land in the new structure.
### #272 — Information boundary negative test suite
The core asymmetric information claim of the game: entity X cannot see what entity Y knows, unless the observation system explicitly grants it.
What this ticket must deliver:
- A suite of negative tests asserting that information does NOT cross boundaries:
1. Player's `KnowledgeGraph` does not contain NPC data that was not observed (no passive leakage)
2. `ObserverSnapshot` for the player does not include entities outside LOS (fog of perception holds)
3. Background-tier NPC `KnowledgeGraph` is not updated by Active-tier systems (tier boundary holds)
4. `SaveStateV1` for one NPC does not serialize another NPC's `KnowledgeGraph`
- Uses `test_world/` for scenario setup — reuse existing helpers
- These tests live in Layer 1 (pure unit) and Layer 2 (mock world) of D-030
Gotcha: "negative tests" means asserting absence. Use `assert!(kg.entities.get(&id).is_none())` patterns — not just "test passed because nothing happened."
### #555 — Protocol version handshake: server
`server/src/bridge/types.rs` defines `PROTOCOL_VERSION: u8 = 14`. The version is already included in `ObserverSnapshot` as `pub version: u8`.
What this ticket must deliver:
- Verify the first `ObserverSnapshot` emitted after subprocess startup includes `version: PROTOCOL_VERSION`
- Add a handshake phase: before normal tick loop begins, server emits a minimal `HandshakeMessage { protocol_version: PROTOCOL_VERSION }` as the very first framed message on the IPC channel
- Client reads this message and validates before sending any `PlayerInput`
- If the server receives a `PlayerInput` before completing handshake, log a warning and process normally (forward-compatible)
- Integration point: `server/src/bridge/local.rs` (startup sequence), `server/src/bridge/framing.rs` (message framing)
Coordinate with ci team (#556) — the client-side validation is their ticket.
## Dependency Chain
```
#555 (protocol handshake: server) → #556 (ci: protocol handshake: client)
#200 (test module organization) → #272 (information boundary tests)
#98 (scope tag system) → feeds into #97 (eviction respects scope pins)
#256 (done: save state data model) → #553 (server ECS extraction)
#553 (server ECS extraction) → #554 (client: save/load UI)
#96 (state serialization) → feeds into #553 (used during ECS extraction)
Parallel starts: #555, #97, #98, #96, #200 — all unblocked week 1
#553 starts after #96 is at review stage (needs serialize_npc_to_frozen)
#272 starts after #200 merges
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
```
+4
View File
@@ -306,6 +306,8 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
@@ -439,6 +441,8 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
+10 -1
View File
@@ -37,7 +37,7 @@ pub const PROTOCOL_VERSION: u8 = 14;
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
/// follow_state (#241, follow mechanic HUD state).
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
/// examine_result (#174, character-filtered observation text from Examine verb),
/// examine_result (#242, character-filtered examine observation text),
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -110,6 +110,15 @@ pub struct ObserverSnapshot {
/// Client shows follow indicator with distance, LOS, and tension.
#[serde(default)]
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
/// Examine result from Examine verb interaction (#242).
/// Present when the player examined an NPC or object this tick.
/// Client displays character-filtered detail text in an observation panel.
#[serde(default)]
pub examine_result: Option<crate::simulation::examine::ExamineResultEvent>,
/// Character pressure state for client HUD widget (#248).
/// Present when pressure is non-zero. Client renders tension indicator.
#[serde(default)]
pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>,
/// RNG seed active at this tick for deterministic replay (#527).
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
/// None when the RNG resource is unavailable (should not occur in practice).
+51
View File
@@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// TODO: CombatCapability — no content schema type exists yet. When combat content
// is authored, add weapon_proficiency + combat_style mapping here.
// Vision + awareness components (#115, #244) — must match generate_npc().
// Without these, vision/awareness systems silently skip content-spawned NPCs.
entity_commands.insert((
npc::vision::NpcVisionState::default(),
npc::vision::NpcMemory::default(),
npc::awareness::PlayerAwareness::default(),
));
let entity = entity_commands.id();
// Register in EntityRegistry for StableId mapping
@@ -1100,6 +1108,49 @@ mod tests {
assert_eq!(edge.trust, 7);
}
#[test]
#[test]
fn spawn_npc_combat_trained_sets_skill_flag() {
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
// path (generate.rs) correctly attaches CombatCapability. This test documents
// current behavior so the gap is visible in CI.
let mut world = create_test_world();
let mut profile = create_test_profile();
profile.skills = Some(NpcSkills {
combat_trained: Some(true),
skills: Some({
let mut m = std::collections::BTreeMap::new();
m.insert("combat".to_string(), 7);
m
}),
});
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let entity = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["test-npc"])
.unwrap();
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert!(
skills.combat_trained,
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
);
// Known gap: CombatCapability not yet attached in content-spawn path.
// The procedural path (generate.rs) does attach it — content path has TODO.
// Update this assertion when the TODO is resolved.
assert!(
world.get::<npc::CombatCapability>(entity).is_none(),
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
);
}
#[test]
fn resolve_relationships_skips_unknown_targets() {
let mut world = create_test_world();
+605
View File
@@ -0,0 +1,605 @@
//! NPC player-awareness system (#244).
//!
//! Tracks how aware an NPC is of the player's attention. When the player
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
//!
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
//! Separate from the follow mechanic (#241): follow tracks player-initiated
//! proximity, awareness tracks NPC-perceived attention.
//!
//! ## System ordering
//!
//! `detect_player_awareness` runs:
//! - after `vision::compute_npc_vision` (needs player_visible)
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
//!
//! ## Suspicion lifecycle
//!
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
//!
//! All arithmetic is integer-only (D-010 determinism).
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::vision::NpcVisionState;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
/// notices sustained player attention. Lighter than follow stress (2/tick)
/// because watching is less intrusive than tailing.
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
/// Ticks between suspicion decay checks when player is NOT in LOS.
/// 10 ticks = 1 game-minute (D-031).
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
/// Suspicion decay amount per interval when player is not in LOS.
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// Tracks an NPC's awareness of sustained player attention (#244).
///
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
///
/// ## Fields
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
/// - `consecutive_los_ticks` — resets when player leaves LOS
/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlayerAwareness {
/// Whether the player is currently in this NPC's line of sight.
pub player_in_los: bool,
/// Consecutive ticks the player has been in this NPC's LOS.
pub consecutive_los_ticks: u64,
/// Accumulated suspicion level (0–100, integer for D-010).
pub suspicion_level: i16,
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Detect sustained player attention and build NPC suspicion.
///
/// For each Active-tier NPC with `PlayerAwareness`:
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
/// - If player visible: increment `consecutive_los_ticks`; once past
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
/// stress to `ToleranceThreshold`
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
/// `suspicion_level` periodically
pub fn detect_player_awareness(
time: Res<SimulationTime>,
mut npc_query: Query<
(
&NpcVisionState,
&mut PlayerAwareness,
&mut ToleranceThreshold,
),
(With<Npc>, With<ActiveSim>),
>,
) {
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
awareness.player_in_los = vision.player_visible;
if vision.player_visible {
awareness.consecutive_los_ticks += 1;
// Once the NPC has noticed sustained player attention, build suspicion
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
awareness.suspicion_level =
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
tolerance.current_stress = tolerance
.current_stress
.saturating_add(AWARENESS_STRESS_PER_TICK);
}
} else {
// Player left LOS — reset consecutive counter
awareness.consecutive_los_ticks = 0;
// Decay suspicion slowly when player is not visible
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
awareness.suspicion_level =
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::vision::NpcVisionState;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_player_awareness);
schedule.run(world);
}
fn default_tolerance() -> ToleranceThreshold {
ToleranceThreshold {
current_stress: 0,
threshold: 80,
}
}
fn vision_with_player(visible: bool) -> NpcVisionState {
NpcVisionState {
player_visible: visible,
..Default::default()
}
}
// -----------------------------------------------------------------------
// Basic LOS tracking
// -----------------------------------------------------------------------
#[test]
fn player_in_los_increments_consecutive_ticks() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness::default(),
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(awareness.player_in_los);
assert_eq!(awareness.consecutive_los_ticks, 1);
}
#[test]
fn player_leaving_los_resets_consecutive_ticks() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: 20,
suspicion_level: 5,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(!awareness.player_in_los);
assert_eq!(awareness.consecutive_los_ticks, 0);
}
#[test]
fn player_in_los_syncs_from_vision_state() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: false, // was false
consecutive_los_ticks: 0,
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(awareness.player_in_los, "should sync from NpcVisionState");
}
// -----------------------------------------------------------------------
// Suspicion threshold — no stress before notice ticks
// -----------------------------------------------------------------------
#[test]
fn no_stress_before_notice_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 0,
"suspicion should not build before notice threshold"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress, 0,
"stress should not increase before notice threshold"
);
}
#[test]
fn stress_starts_at_notice_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
"consecutive ticks should reach threshold"
);
assert_eq!(
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
"suspicion should increase at threshold"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
"stress should increase at threshold"
);
}
// -----------------------------------------------------------------------
// Suspicion accumulation
// -----------------------------------------------------------------------
#[test]
fn suspicion_accumulates_past_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
suspicion_level: 10,
},
ToleranceThreshold {
current_stress: 20,
threshold: 80,
},
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level,
10 + AWARENESS_STRESS_PER_TICK,
"suspicion should accumulate"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
20 + AWARENESS_STRESS_PER_TICK,
"stress should accumulate"
);
}
#[test]
fn suspicion_caps_at_100() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
suspicion_level: 100,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
}
#[test]
fn tolerance_stress_saturates() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
suspicion_level: 50,
},
ToleranceThreshold {
current_stress: i16::MAX - 1,
threshold: i16::MAX,
},
))
.id();
run_system(&mut world);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
i16::MAX,
"stress should saturate, not overflow"
);
}
// -----------------------------------------------------------------------
// Suspicion decay
// -----------------------------------------------------------------------
#[test]
fn suspicion_decays_when_player_not_in_los_on_interval() {
let mut world = setup_world();
// Set tick to a decay interval boundary
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 10,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level,
10 - AWARENESS_DECAY_AMOUNT,
"suspicion should decay on interval tick"
);
}
#[test]
fn suspicion_does_not_decay_off_interval() {
let mut world = setup_world();
// Set tick to a non-interval boundary
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 10,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 10,
"suspicion should not decay on non-interval tick"
);
}
#[test]
fn suspicion_does_not_go_below_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 0,
"suspicion should not go below zero"
);
}
// -----------------------------------------------------------------------
// Background NPC not processed
// -----------------------------------------------------------------------
#[test]
fn background_npc_not_processed() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
crate::simulation::tier::BackgroundSim,
vision_with_player(true),
PlayerAwareness::default(),
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.consecutive_los_ticks, 0,
"background NPC should not have awareness processed"
);
}
// -----------------------------------------------------------------------
// No panic with missing components
// -----------------------------------------------------------------------
#[test]
fn npc_without_awareness_no_panic() {
let mut world = setup_world();
// NPC with vision but no PlayerAwareness — system should skip
world.spawn((
Npc,
ActiveSim,
vision_with_player(true),
default_tolerance(),
));
run_system(&mut world); // should not panic
}
// -----------------------------------------------------------------------
// Integration: follow + awareness stress stacking
// -----------------------------------------------------------------------
#[test]
fn awareness_stress_stacks_with_existing_stress() {
let mut world = setup_world();
// NPC already has stress from other sources (e.g., follow mechanic)
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
suspicion_level: 5,
},
ToleranceThreshold {
current_stress: 30, // pre-existing stress
threshold: 80,
},
))
.id();
run_system(&mut world);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
30 + AWARENESS_STRESS_PER_TICK,
"awareness stress should stack with existing stress"
);
}
// -----------------------------------------------------------------------
// Constant value assertions (#244 spec compliance)
// -----------------------------------------------------------------------
#[test]
fn awareness_constants_have_expected_values() {
assert_eq!(
AWARENESS_NOTICE_TICKS, 30,
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
);
assert_eq!(
AWARENESS_STRESS_PER_TICK, 1,
"#244: stress per tick lighter than follow stress (D-010 integer)"
);
assert_eq!(
AWARENESS_DECAY_INTERVAL, 10,
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
);
assert_eq!(
AWARENESS_DECAY_AMOUNT, 1,
"#244: suspicion decays by 1 per interval"
);
}
}
+730
View File
@@ -0,0 +1,730 @@
//! Background tier state machines (#95, D-026).
//!
//! Four lightweight state machines for Background-tier NPCs, firing once per
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
//!
//! ## State machines
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
//!
//! ## Design constraints (D-026)
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
//! - All arithmetic is integer-only (D-010 determinism requirement).
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
use bevy_ecs::prelude::*;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::simulation::tier::BackgroundSim;
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
// ---------------------------------------------------------------------------
// Mood derivation (background-tier)
// ---------------------------------------------------------------------------
/// Derive simplified mood for a Background-tier NPC.
///
/// No phase or warm-flag considerations — background NPCs have no active
/// interactions. Priority order:
/// 1. Hostile — stress ≥ threshold
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
/// 3. Content — stress < 20
/// 4. Neutral — otherwise
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
// 1. Hostile: at or above threshold
if current_stress >= threshold {
return NpcMood::Hostile;
}
// 2. Anxious: 60% of threshold reached
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
return NpcMood::Anxious;
}
// 3. Content: low stress
if current_stress < 20 {
return NpcMood::Content;
}
// 4. Neutral: default
NpcMood::Neutral
}
// ---------------------------------------------------------------------------
// Job performance drift
// ---------------------------------------------------------------------------
/// Drift job performance score one point per game-minute based on contentment.
///
/// - contentment > 0 → score + 1 (clamped at 100)
/// - contentment < 0 → score - 1 (clamped at 0)
/// - contentment = 0 → unchanged
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
match contentment_level.cmp(&0) {
std::cmp::Ordering::Greater => (score + 1).min(100),
std::cmp::Ordering::Less => (score - 1).max(0),
std::cmp::Ordering::Equal => score,
}
}
// ---------------------------------------------------------------------------
// System: background_tick
// ---------------------------------------------------------------------------
/// System: run all four background-tier state machines once per game-minute.
///
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
/// per-tick systems.
///
/// Machine execution order per NPC:
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
///
/// Commands for `ActivityState` are deferred (applied after system runs).
/// Mutable component mutations happen immediately within the iteration.
pub fn background_tick(
time: Res<SimulationTime>,
mut commands: Commands,
mut query: Query<
(
Entity,
Option<&ActivityState>,
&mut MoodState,
Option<&mut Relationships>,
Option<&ToleranceThreshold>,
Option<&DailyRoutine>,
Option<&Contentment>,
Option<&mut JobPerformance>,
),
(With<Npc>, With<BackgroundSim>),
>,
) {
// Fire once per game-minute (D-031: 10 ticks/minute)
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
return;
}
let phase = time.day_phase();
let tick = time.tick;
for (
entity,
activity_opt,
mut mood_state,
rels_opt,
tolerance_opt,
routine_opt,
contentment_opt,
job_opt,
) in query.iter_mut()
{
// --- 1. Schedule: sync ActivityState to current DayPhase ---
// Background NPCs don't pathfind — we directly declare the activity.
if let Some(routine) = routine_opt {
if let Some(entry) = routine.entry_for_phase(phase) {
let needs_update = match activity_opt {
Some(a) => a.phase != phase || a.activity != entry.activity,
None => true,
};
if needs_update {
commands.entity(entity).insert(ActivityState {
activity: entry.activity.clone(),
phase,
started_tick: tick,
});
}
} else if activity_opt.is_some() {
// No routine entry for this phase — clear stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
// --- 2. Mood: simplified stress-based derivation ---
let (stress, threshold) = tolerance_opt
.map(|t| (t.current_stress, t.threshold))
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
let new_mood = derive_background_mood(stress, threshold);
if mood_state.mood != new_mood {
mood_state.mood = new_mood;
mood_state.changed_tick = tick;
}
// --- 3. Relationships: trust drift toward 0 (baseline) ---
if let Some(mut rels) = rels_opt {
for rel in &mut rels.entries {
if rel.trust_level > 0 {
rel.trust_level -= 1;
} else if rel.trust_level < 0 {
rel.trust_level += 1;
}
}
}
// --- 4. Job: performance drift from contentment ---
if let Some(mut job) = job_opt {
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
job.score = drift_job_performance(job.score, contentment);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
Relationships, RoutineEntry, ToleranceThreshold,
};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::simulation::movement::TilePosition;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
use crate::knowledge::types::StableId;
use bevy_ecs::world::World;
// --- derive_background_mood ---
#[test]
fn background_mood_hostile_at_threshold() {
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_hostile_above_threshold() {
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_anxious_at_60_percent() {
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
}
#[test]
fn background_mood_anxious_boundary_below_threshold() {
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
}
#[test]
fn background_mood_content_low_stress() {
// stress=10 < 20 → Content (not hostile, not anxious)
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
}
#[test]
fn background_mood_neutral_moderate_stress() {
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
// not content (25 >= 20) → Neutral
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
}
#[test]
fn background_mood_zero_threshold_is_hostile() {
// stress=0 >= threshold=0 → Hostile
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
}
#[test]
fn background_mood_zero_stress_moderate_threshold_is_content() {
// stress=0 < 20 → Content
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
}
// --- drift_job_performance ---
#[test]
fn job_drift_up_when_positive_contentment() {
assert_eq!(drift_job_performance(50, 10), 51);
}
#[test]
fn job_drift_down_when_negative_contentment() {
assert_eq!(drift_job_performance(50, -10), 49);
}
#[test]
fn job_drift_unchanged_at_zero_contentment() {
assert_eq!(drift_job_performance(50, 0), 50);
}
#[test]
fn job_drift_clamps_at_100() {
assert_eq!(drift_job_performance(100, 5), 100);
}
#[test]
fn job_drift_clamps_at_0() {
assert_eq!(drift_job_performance(0, -5), 0);
}
// --- background_tick system integration tests ---
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(background_tick);
schedule.run(world);
}
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
DailyRoutine {
entries: vec![RoutineEntry {
phase,
location: TilePosition::new(5, 5, 0),
activity: activity.to_string(),
}],
description: "Test routine".into(),
}
}
// --- Tick gating ---
#[test]
fn does_not_fire_on_non_minute_tick() {
let mut world = setup_world();
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
world.resource_mut::<SimulationTime>().tick = 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
// Mood should NOT have updated (Hostile would fire if it ran)
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
}
#[test]
fn fires_at_tick_zero() {
let mut world = setup_world();
// tick=0 is 0 % 10 == 0, so it fires
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
#[test]
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
// --- Active-tier NPCs not processed ---
#[test]
fn active_npcs_not_processed() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// ActiveSim NPC — must NOT be processed by background_tick
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
}
// --- Mood state machine ---
#[test]
fn mood_hostile_when_stress_at_threshold() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 50, threshold: 50 },
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
}
#[test]
fn mood_content_when_no_tolerance() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
let npc = world
.spawn((Npc, BackgroundSim, MoodState::default()))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
}
#[test]
fn mood_records_changed_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
ToleranceThreshold { current_stress: 55, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
}
#[test]
fn mood_unchanged_tick_not_updated() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// Already Content, will derive Content → no change to changed_tick
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState { mood: NpcMood::Content, changed_tick: 42 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Content);
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
}
// --- Schedule state machine ---
#[test]
fn schedule_sets_activity_state_for_current_phase() {
let mut world = setup_world();
// tick=0 → DayPhase::Morning
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
assert_eq!(activity.phase, DayPhase::Morning);
}
#[test]
fn schedule_no_activity_when_no_routine_entry_for_phase() {
let mut world = setup_world();
// tick=0 → Morning, but routine only has Afternoon
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Afternoon, "Meeting"),
))
.id();
run_system(&mut world);
// No ActivityState should be inserted (Morning has no entry)
assert!(world.get::<ActivityState>(npc).is_none());
}
#[test]
fn schedule_preserves_correct_activity_state() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0; // Morning
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
// Already has correct ActivityState — should not be re-inserted
ActivityState {
activity: "Work".into(),
phase: DayPhase::Morning,
started_tick: 0,
},
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
}
// --- Relationships state machine ---
#[test]
fn relationships_positive_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(1),
kind: RelationshipKind::Friend,
trust_level: 5,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
}
#[test]
fn relationships_negative_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(2),
kind: RelationshipKind::Rival,
trust_level: -4,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
}
#[test]
fn relationships_zero_trust_stays_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(3),
kind: RelationshipKind::Colleague,
trust_level: 0,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
}
// --- Job state machine ---
#[test]
fn job_performance_rises_with_positive_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: 20 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 61);
}
#[test]
fn job_performance_falls_with_negative_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: -15 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 59);
}
#[test]
fn job_performance_unchanged_at_zero_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 50 },
Contentment { level: 0 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 50);
}
#[test]
fn job_performance_without_component_no_panic() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No JobPerformance — system must not panic
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
run_system(&mut world); // must not panic
}
// --- Multiple NPCs independent ---
#[test]
fn multiple_background_npcs_processed_independently() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let calm = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 5, threshold: 50 },
))
.id();
let hostile = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 55, threshold: 50 },
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
}
}
+81 -5
View File
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
use crate::npc::{
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
};
use crate::knowledge::graph::KnowledgeGraph;
use crate::npc::mood::MoodState;
use crate::npc::awareness::PlayerAwareness;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
@@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
DailyRoutine {
entries,
description: format!("Routine schedule"),
description: "Routine schedule".to_string(),
}
}
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
tells,
skills,
MoodState::default(),
JobPerformance::default(),
));
// Vision + knowledge + awareness components (#115, #244, D-041).
// Separate insert to stay within tuple bundle element limit.
entity_builder.insert((
KnowledgeGraph::new(),
NpcVisionState::default(),
NpcMemory::default(),
PlayerAwareness::default(),
));
if let Some(cap) = combat_opt {
@@ -876,6 +889,69 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// Combat capability — positive case (#91, D-024)
// -----------------------------------------------------------------------
#[test]
fn combat_role_probabilistically_produces_combat_capability() {
// #91 positive-case: at least one seed must produce CombatCapability for
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
// so 30 seeds is overwhelmingly likely to produce at least one match.
let role = minimal_role(); // combat_enabled = true
let mut any_combat = false;
for seed in 0..30_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
if world.get::<CombatCapability>(entity).is_some() {
any_combat = true;
break;
}
}
assert!(
any_combat,
"combat-enabled role must produce CombatCapability for at least one seed"
);
}
#[test]
fn combat_trained_flag_matches_combat_capability_presence() {
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
// If CombatCapability is present, combat_trained must be true and vice versa.
let role = minimal_role(); // combat_enabled = true
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let skills = world.get::<SkillSet>(entity).unwrap();
let has_cap = world.get::<CombatCapability>(entity).is_some();
assert_eq!(
skills.combat_trained, has_cap,
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
skills.combat_trained, has_cap
);
}
}
#[test]
fn combat_capability_proficiency_in_valid_range() {
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
let role = minimal_role(); // combat_enabled = true
for seed in 0..100_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
if let Some(cap) = world.get::<CombatCapability>(entity) {
assert!(
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
cap.weapon_proficiency
);
}
}
}
// -----------------------------------------------------------------------
// ActiveSim tier
// -----------------------------------------------------------------------
+50
View File
@@ -2,6 +2,8 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod awareness;
pub mod background;
pub mod disclosure;
pub mod generate;
pub mod interaction;
@@ -11,6 +13,7 @@ pub mod routine;
pub mod tell_state;
pub mod tolerance;
pub mod trait_modifiers;
pub mod vision;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<relationships::PropagationQueue>()
.init_resource::<relationships::DelayedTrustQueue>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.init_resource::<tolerance::ToleranceBreachEventQueue>()
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
.after(crate::simulation::dialogue::process_confrontation_response)
.after(crate::simulation::dialogue::process_dialogue_response)
.before(crate::simulation::time::advance_tick),
relationships::propagate_social_actions
.after(relationships::update_trust)
.before(crate::simulation::time::advance_tick),
relationships::update_relationship_dynamics
.after(relationships::update_trust)
.before(crate::simulation::time::advance_tick),
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
.after(mood::update_mood)
.after(routine::detect_routine_deviation)
.before(crate::perception::observer::compute_observer_snapshot),
background::background_tick
.after(crate::simulation::movement::validate_movement)
.before(crate::simulation::time::advance_tick),
disclosure::derive_disclosure_candidates
.before(crate::perception::observer::compute_observer_snapshot),
disclosure::process_unprompted_disclosure
.after(disclosure::derive_disclosure_candidates)
.before(crate::perception::observer::compute_observer_snapshot),
// NPC player-awareness (#244)
awareness::detect_player_awareness
.after(vision::compute_npc_vision)
.before(tolerance::check_tolerance_threshold),
// NPC vision system (#115, D-011)
vision::compute_npc_vision
.after(crate::simulation::movement::validate_movement)
.after(crate::simulation::tier::update_tier_markers)
.before(crate::perception::observer::compute_observer_snapshot),
vision::emit_npc_vision_events
.after(vision::compute_npc_vision)
.before(crate::knowledge::events::process_knowledge_events),
vision::degrade_npc_inferences
.after(vision::emit_npc_vision_events)
.before(crate::simulation::time::advance_tick),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
@@ -268,6 +294,30 @@ pub struct Contentment {
pub level: i16, // -100..+100, integer for determinism (D-010)
}
// ---------------------------------------------------------------------------
// Job performance (D-026 background state machine — feeds from Contentment)
// ---------------------------------------------------------------------------
/// Tracks how well an NPC performs their job role.
///
/// Drifts based on `Contentment` level in the background-tier state machine:
/// - contentment > 0 → score increases toward 100
/// - contentment < 0 → score decreases toward 0
/// - contentment = 0 → no change
///
/// Updated by `background::background_tick` once per game-minute for
/// Background-tier NPCs. Persists through tier promotions (D-026).
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct JobPerformance {
pub score: i16, // 0..=100, integer for determinism (D-010)
}
impl Default for JobPerformance {
fn default() -> Self {
Self { score: 50 }
}
}
// ---------------------------------------------------------------------------
// Supporting axis 1: Personality traits (D-024)
// ---------------------------------------------------------------------------
+592 -1
View File
@@ -141,7 +141,8 @@ impl RelationshipGraph {
}
/// Get all entities who have feelings about a target.
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
/// O(N) full scan of all edges. Called once per game-minute (every 10 ticks)
/// by the pressure system — acceptable at v0.1 NPC counts.
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.iter()
@@ -201,16 +202,121 @@ impl RelationshipGraph {
// System: update_trust (#324)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Social propagation (#249, D-029)
// ---------------------------------------------------------------------------
/// Minimum absolute trust required on a relationship edge for that edge
/// to carry propagation (D-029: "strong relationships", trust > 3).
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
/// Delay in ticks before third-order propagation is applied.
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
/// A first-order trust change that needs social propagation.
///
/// Produced by `update_trust` for each processed event, consumed by
/// `propagate_social_actions` to fan out second- and third-order effects.
#[derive(Debug, Clone)]
pub struct PropagationEvent {
/// The NPC directly affected by the player action (first-order subject).
pub npc: StableId,
/// The player entity (target of how NPCs feel).
pub player: StableId,
/// The first-order delta (same as what was applied to the graph).
pub delta: i8,
}
/// Queue of propagation events emitted by `update_trust`.
#[derive(Resource, Default)]
pub struct PropagationQueue {
events: Vec<PropagationEvent>,
}
impl PropagationQueue {
pub fn push(&mut self, event: PropagationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<PropagationEvent> {
std::mem::take(&mut self.events)
}
}
/// A deferred trust change for third-order propagation.
#[derive(Debug, Clone)]
pub struct DelayedTrustChange {
/// NPC whose trust toward the player will be adjusted.
pub npc: StableId,
/// Player entity.
pub player: StableId,
/// Scaled delta to apply (already clamped to meaningful range).
pub delta: i8,
/// Tick at which this change should be applied.
pub apply_at_tick: u64,
}
/// Queue of deferred third-order trust changes.
#[derive(Resource, Default)]
pub struct DelayedTrustQueue {
pending: Vec<DelayedTrustChange>,
}
impl DelayedTrustQueue {
pub fn push(&mut self, change: DelayedTrustChange) {
self.pending.push(change);
}
/// Drain changes that are due at or before `current_tick`.
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
let mut due = Vec::new();
let mut remaining = Vec::new();
for change in self.pending.drain(..) {
if change.apply_at_tick <= current_tick {
due.push(change);
} else {
remaining.push(change);
}
}
self.pending = remaining;
due
}
/// Number of pending deferred changes.
pub fn pending_count(&self) -> usize {
self.pending.len()
}
}
/// Scale a first-order delta by a propagation factor, using integer arithmetic
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
/// round to zero — small deltas naturally attenuate to nothing.
///
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
///
/// Rounding: away from zero (ceiling of abs value, preserving sign).
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
// Multiply by factor, round up (ceiling of absolute value)
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
let scaled = scaled_abs.min(10) as i8;
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
}
/// Drain pending trust events and apply deltas to the RelationshipGraph.
///
/// Each event adjusts the NPC→player trust edge. If no edge exists,
/// one is created with default Colleague kind and trust 0 before applying
/// the delta. Trust is clamped to [-10, +10] per D-010.
///
/// Also queues a `PropagationEvent` for each processed event so that
/// `propagate_social_actions` can fan out second- and third-order effects.
///
/// System ordering: after dialogue systems (which emit the events),
/// before advance_tick.
pub fn update_trust(
mut queue: ResMut<TrustEventQueue>,
mut propagation_queue: ResMut<PropagationQueue>,
mut graph: ResMut<RelationshipGraph>,
registry: Res<EntityRegistry>,
time: Res<SimulationTime>,
@@ -244,6 +350,116 @@ pub fn update_trust(
new_trust = edge.trust,
"Trust updated"
);
// Queue propagation event for second/third-order effects (#249)
propagation_queue.push(PropagationEvent {
npc: npc_sid,
player: player_sid,
delta,
});
}
}
/// System: fan out player-action trust changes through the social graph (#249, D-029).
///
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
///
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
/// the `RelationshipGraph` is seeded differently per run.
///
/// System ordering: after `update_trust`, before `advance_tick`.
pub fn propagate_social_actions(
mut propagation_queue: ResMut<PropagationQueue>,
mut delay_queue: ResMut<DelayedTrustQueue>,
mut graph: ResMut<RelationshipGraph>,
time: Res<SimulationTime>,
) {
// --- Apply any due delayed third-order changes first ---
for change in delay_queue.drain_due(time.tick) {
if change.delta != 0 {
graph.ensure_edge(change.npc, change.player, time.tick);
graph.adjust_trust(&change.npc, &change.player, change.delta);
tracing::trace!(
npc = change.npc.0,
player = change.player.0,
delta = change.delta,
"Third-order trust propagated (delayed)"
);
}
}
// --- Fan out new propagation events ---
for event in propagation_queue.drain() {
let PropagationEvent { npc, player, delta } = event;
// Visited set prevents cycles (D-029)
let mut visited = std::collections::BTreeSet::new();
visited.insert(npc);
// --- Second-order (immediate, 40% of delta) ---
let second_delta = scale_delta(delta, 4); // 40%
if second_delta != 0 {
// Find NPCs that the first-order NPC trusts strongly
let second_order: Vec<StableId> = graph
.relationships_of(&npc)
.into_iter()
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
.map(|(target, _)| *target)
.collect();
for second in &second_order {
if visited.contains(second) {
continue;
}
visited.insert(*second);
graph.ensure_edge(*second, player, time.tick);
graph.adjust_trust(second, &player, second_delta);
tracing::trace!(
first_order_npc = npc.0,
second_order_npc = second.0,
player = player.0,
delta = second_delta,
"Second-order trust propagated"
);
}
// --- Third-order (delayed 30 ticks, 15% of delta) ---
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
if third_delta != 0 {
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
for second in &second_order {
let third_order: Vec<StableId> = graph
.relationships_of(second)
.into_iter()
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
.map(|(target, _)| *target)
.collect();
for third in third_order {
if visited.contains(&third) {
continue;
}
visited.insert(third);
delay_queue.push(DelayedTrustChange {
npc: third,
player,
delta: third_delta,
apply_at_tick: apply_at,
});
tracing::trace!(
third_order_npc = third.0,
player = player.0,
delta = third_delta,
apply_at,
"Third-order trust queued for delayed propagation"
);
}
}
}
}
}
}
@@ -493,6 +709,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<PropagationQueue>();
world
}
@@ -748,6 +965,380 @@ mod tests {
assert_eq!(edge.trust, 6); // 5 + 1
}
// -- PropagationQueue tests (#249) ----------------------------------------
#[test]
fn propagation_queue_push_and_drain() {
let mut q = PropagationQueue::default();
q.push(PropagationEvent {
npc: StableId(1),
player: StableId(2),
delta: 1,
});
q.push(PropagationEvent {
npc: StableId(3),
player: StableId(2),
delta: -2,
});
let drained = q.drain();
assert_eq!(drained.len(), 2);
assert!(q.drain().is_empty());
}
#[test]
fn delayed_trust_queue_drain_due_filters_by_tick() {
let mut q = DelayedTrustQueue::default();
q.push(DelayedTrustChange {
npc: StableId(1),
player: StableId(10),
delta: 1,
apply_at_tick: 50,
});
q.push(DelayedTrustChange {
npc: StableId(2),
player: StableId(10),
delta: -1,
apply_at_tick: 100,
});
// Only change at tick 50 is due at tick 60
let due = q.drain_due(60);
assert_eq!(due.len(), 1);
assert_eq!(due[0].npc, StableId(1));
// Change at tick 100 is still pending
assert_eq!(q.pending_count(), 1);
// At tick 100 it becomes due
let due2 = q.drain_due(100);
assert_eq!(due2.len(), 1);
assert_eq!(due2[0].npc, StableId(2));
assert_eq!(q.pending_count(), 0);
}
#[test]
fn scale_delta_forty_percent() {
// factor_tenths=4 → 40%
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
}
#[test]
fn scale_delta_zero_when_too_small() {
// delta=0 → always 0
assert_eq!(scale_delta(0, 4), 0);
}
// -- propagate_social_actions system tests (#249) -------------------------
fn setup_propagation_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<PropagationQueue>();
world.init_resource::<DelayedTrustQueue>();
world
}
#[test]
fn second_order_trust_propagates_immediately() {
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
// receive 40% of the delta in the same tick.
//
// Graph: A → B (trust 5, > threshold 3)
// A → player (will be first-order)
// Event: player action affects A (delta=+2)
// Expected: B gains 40% of 2 = 1 (ceil) toward player
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// A strongly trusts B (A→B trust=5)
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
// Queue propagation from A
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 2,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should now have a trust edge toward the player (positive)
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
assert!(
edge.trust > 0,
"Second-order NPC should trust player more after positive first-order event"
);
}
#[test]
fn weak_relationship_does_not_propagate() {
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
//
// Graph: A → B (trust 2, ≤ threshold 3)
// Event: player action affects A (delta=+5)
// Expected: B gets no propagation (trust ≤ threshold)
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// A weakly trusts B (below threshold)
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Colleague, 2),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should NOT have any edge to the player
let graph = world.resource::<RelationshipGraph>();
assert!(
graph.get_relationship(&npc_b, &player).is_none(),
"Weak relationship should not carry propagation"
);
}
#[test]
fn third_order_trust_is_deferred_by_thirty_ticks() {
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
//
// Graph: A → B (trust 5), B → C (trust 5)
// Event: player action affects A (delta=+5)
// Expected: C's change is queued for tick+30, not applied immediately
let mut world = setup_propagation_world();
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
let npc_a = StableId(1);
let npc_b = StableId(2);
let npc_c = StableId(3);
let player = StableId(99);
let mut graph = world.resource_mut::<RelationshipGraph>();
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// C should NOT have an edge yet (it's deferred)
{
let graph = world.resource::<RelationshipGraph>();
assert!(
graph.get_relationship(&npc_c, &player).is_none(),
"Third-order changes should be deferred, not applied immediately"
);
}
// Delay queue should have one pending change for C at tick 10+30=40
let delay_queue = world.resource::<DelayedTrustQueue>();
assert_eq!(delay_queue.pending_count(), 1);
}
#[test]
fn delayed_changes_applied_when_due() {
// Once the tick advances past apply_at_tick, the delayed change is applied.
let mut world = setup_propagation_world();
world.resource_mut::<SimulationTime>().tick = 10;
let npc_a = StableId(1);
let npc_b = StableId(2);
let npc_c = StableId(3);
let player = StableId(99);
let mut graph = world.resource_mut::<RelationshipGraph>();
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
// First run: queue the third-order change
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// Advance tick to 40 (past apply_at_tick = 40)
world.resource_mut::<SimulationTime>().tick = 40;
schedule.run(&mut world);
// C should now have an edge with positive trust
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_c, &player)
.expect("Delayed change should have been applied by now");
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
}
#[test]
fn cycle_prevention_no_a_to_b_to_a_loop() {
// Spec (#249, D-029): visited set prevents A→B→A cycles.
//
// Graph: A ↔ B (both trust each other at 5)
// Event: player action affects A (delta=+2)
// A should propagate to B, but B should NOT propagate back to A.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// Bidirectional strong trust
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_b,
npc_a,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 2,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// A should not get a second-order change from the cycle (A was the origin)
let graph = world.resource::<RelationshipGraph>();
// The only A→player edge effect should be through the propagation event
// (no direct creation in propagate_social_actions — only update_trust does that).
// B should have an edge to player.
assert!(
graph.get_relationship(&npc_b, &player).is_some(),
"B should get second-order propagation from A"
);
// A should NOT have a re-propagated edge (cycle prevented)
assert!(
graph.get_relationship(&npc_a, &player).is_none(),
"A should not receive back-propagation from B (cycle prevention)"
);
}
#[test]
fn negative_delta_propagates_as_negative() {
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
// with the same sign.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: -2, // confrontation
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should trust player LESS after A was confronted
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_b, &player)
.expect("B should have edge to player");
assert!(
edge.trust < 0,
"Negative propagation should reduce B's trust in player"
);
}
#[test]
fn no_relationships_means_no_propagation() {
// If the first-order NPC has no relationships, the queue is drained
// but nothing propagates.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let player = StableId(99);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
let graph = world.resource::<RelationshipGraph>();
assert!(graph.is_empty(), "No relationships → no propagation edges created");
let delay_queue = world.resource::<DelayedTrustQueue>();
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
}
#[test]
fn update_trust_populates_propagation_queue() {
// Integration: update_trust should push to PropagationQueue for downstream #249.
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
// PropagationQueue should have 1 event (for downstream propagation)
let prop_events = world.resource_mut::<PropagationQueue>().drain();
assert_eq!(prop_events.len(), 1);
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
}
#[test]
fn unregistered_entity_event_is_skipped() {
let mut world = setup_trust_world();
+826
View File
@@ -0,0 +1,826 @@
//! NPC vision system (#115, D-011).
//!
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
//! positions and zone inferences ("saw you enter building → knows you're
//! inside").
//!
//! ## System ordering
//!
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
//!
//! ## Performance
//!
//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within
//! D-026 Active tier budget by architecture review.
use std::collections::{BTreeMap, BTreeSet};
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::knowledge::types::StableId;
use crate::knowledge::{
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use crate::npc::Npc;
use crate::perception::shadowcast::compute_fov;
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
use crate::simulation::tier::ActiveSim;
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
pub const NPC_VISION_RANGE: i32 = 20;
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Current field-of-view results for an NPC.
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
#[derive(Component, Debug, Clone, Default)]
pub struct NpcVisionState {
/// StableIds of entities currently in this NPC's LOS.
pub visible_entities: BTreeSet<StableId>,
/// Whether the player character is currently visible.
pub player_visible: bool,
}
/// Persistent memory of entities this NPC has seen.
/// Survives after entities leave LOS (D-011: "saw you enter building →
/// knows you're inside").
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct NpcMemory {
/// Last known position + tick for entities this NPC has seen.
/// Key: StableId of the observed entity.
pub last_known: BTreeMap<StableId, LastKnownEntry>,
}
/// Record of last-known position for a single entity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastKnownEntry {
/// Position where entity was last seen.
pub position: TilePosition,
/// Tick when the entity was last observed.
pub observed_tick: u64,
/// Zone inference: if entity was last seen before leaving LOS,
/// the NPC infers they are still nearby.
pub zone_inference: Option<ZoneInference>,
}
/// Inference that an entity is still near a position based on last observation.
/// Degrades after `degrades_at_tick`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneInference {
/// The position where the entity was last seen.
pub last_seen_position: TilePosition,
/// Tick when the entity was seen at this position.
pub observed_tick: u64,
/// Tick at which this inference degrades (NPC stops assuming entity is here).
pub degrades_at_tick: u64,
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Compute NPC field-of-view for all Active-tier NPCs.
///
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
/// apply vision cone if the NPC has a `Facing` component, then check which entities
/// from the spatial index are at visible tiles.
pub fn compute_npc_vision(
walkability: Option<Res<WalkabilityMap>>,
registry: Res<EntityRegistry>,
spatial_index: Res<NaiveSpatialIndex>,
player_query: Query<Entity, With<PlayerCharacter>>,
mut npc_query: Query<
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
(With<Npc>, With<ActiveSim>),
>,
entity_positions: Query<&TilePosition>,
) {
let Some(walkability) = walkability else {
return;
};
let player_entity = player_query.iter().next();
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
let config = VisionConeConfig::default();
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
let z = npc_pos.z;
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
let fov = compute_fov(
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
npc_pos.x,
npc_pos.y,
NPC_VISION_RANGE,
z,
);
// Collect visible tile positions — apply vision cone if NPC has facing
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
let cone_tiles =
apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
} else {
// No facing → omnidirectional vision (full FOV)
fov.visible_tiles().collect()
};
// Find entities at visible positions via spatial index
let mut new_visible = BTreeSet::new();
let mut player_vis = false;
// Single-pass query: all entities within vision range including own tile
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
for entity in candidates {
if entity == npc_entity {
continue;
}
let Ok(entity_pos) = entity_positions.get(entity) else {
continue;
};
if entity_pos.z != z {
continue;
}
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
continue;
}
let Some(stable_id) = registry.to_stable(entity) else {
continue;
};
new_visible.insert(stable_id);
if Some(stable_id) == player_stable_id {
player_vis = true;
}
}
vision_state.visible_entities = new_visible;
vision_state.player_visible = player_vis;
}
}
/// Emit knowledge events when NPCs gain or lose sight of the player.
///
/// Mirrors the player's `emit_observation_events` pattern but scoped to
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
/// for direct query by downstream systems (#244 awareness) without flooding
/// the knowledge event queue.
///
/// Also updates `NpcMemory` with last-known positions and zone inferences.
pub fn emit_npc_vision_events(
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut event_queue: ResMut<KnowledgeEventQueue>,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
mut npc_query: Query<
(
Entity,
&NpcVisionState,
&mut NpcMemory,
Option<&KnowledgeGraph>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
let Ok((player_entity, player_pos)) = player_query.single() else {
return;
};
let Some(player_sid) = registry.to_stable(player_entity) else {
return;
};
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
if vision_state.player_visible {
// Player is in LOS — update memory and emit DirectObservation
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: *player_pos,
observed_tick: time.tick,
zone_inference: None, // Active observation clears inference
},
);
event_queue.push(KnowledgeEvent {
observer: npc_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target: player_entity,
position: *player_pos,
},
});
} else {
// Player NOT in LOS — check if they WERE Direct (just left)
let was_direct = knowledge_graph
.and_then(|kg| {
kg.entity_knowledge(&player_sid)
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
})
.unwrap_or(false);
if was_direct {
// Player just left this NPC's LOS — emit LeftLOS
event_queue.push(KnowledgeEvent {
observer: npc_entity,
tick: time.tick,
event_type: KnowledgeEventType::LeftLOS {
target: player_entity,
},
});
// Create zone inference — NPC remembers where they last saw the player
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
entry.zone_inference = Some(ZoneInference {
last_seen_position: entry.position,
observed_tick: entry.observed_tick,
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
});
}
}
}
}
}
/// Degrade stale zone inferences in NPC memory.
///
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
/// inference passes its degradation tick, the inference is removed.
pub fn degrade_npc_inferences(
time: Res<SimulationTime>,
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
) {
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
return;
}
for mut memory in npc_query.iter_mut() {
for entry in memory.last_known.values_mut() {
if let Some(ref inference) = entry.zone_inference {
if time.tick >= inference.degrades_at_tick {
entry.zone_inference = None;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use bevy_ecs::world::World;
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<NaiveSpatialIndex>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world
}
fn pos(x: i32, y: i32) -> TilePosition {
TilePosition::new(x, y, 0)
}
// --- compute_npc_vision tests ---
#[test]
fn npc_sees_nearby_entity_in_open_field() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
let npc_sid = registry.register(npc);
spatial.update(npc, pos(16, 16));
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
// Player entity (required for player_stable_id lookup)
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
registry.register(player);
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.contains(&target_sid),
"NPC should see nearby entity in open field"
);
assert!(!vision.player_visible, "player is far away");
let _ = npc_sid; // registered for completeness
}
#[test]
fn npc_cannot_see_through_wall() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target behind a wall
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
// Wall between NPC and target
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&pos(16, 15), false);
drop(walkability);
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity behind wall"
);
}
#[test]
fn npc_detects_player_visible() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
registry.register(player);
spatial.update(player, pos(16, 14));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(vision.player_visible, "NPC should detect player in LOS");
}
#[test]
fn npc_does_not_see_entity_on_different_z_level() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target at same x/y but different z
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
let target_sid = registry.register(target);
spatial.update(target, TilePosition::new(16, 14, 1));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity on different z-level"
);
}
#[test]
fn npc_does_not_see_entity_beyond_range() {
let mut world = setup_world(64, 64);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target beyond NPC_VISION_RANGE (20 tiles)
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity beyond vision range"
);
}
#[test]
fn npc_does_not_see_self() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
let npc_sid = registry.register(npc);
spatial.update(npc, pos(16, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&npc_sid),
"NPC should not include itself in visible entities"
);
}
#[test]
fn npc_sees_non_npc_entity_on_same_tile() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// NPC at (16, 16)
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Non-NPC entity on the same tile (e.g. dropped item)
let item = world.spawn(pos(16, 16)).id();
let item_sid = registry.register(item);
spatial.update(item, pos(16, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.contains(&item_sid),
"NPC should see non-NPC entity sharing its tile"
);
}
#[test]
fn background_npc_not_processed() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// Background NPC — should NOT have its vision computed
let npc = world
.spawn((
Npc,
crate::simulation::tier::BackgroundSim,
pos(16, 16),
NpcVisionState::default(),
))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.is_empty(),
"Background NPC should not have vision computed"
);
let _ = target_sid;
}
// --- emit_npc_vision_events tests ---
#[test]
fn npc_seeing_player_emits_direct_observation() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
let player_sid = registry.register(player);
let mut vision = NpcVisionState::default();
vision.visible_entities.insert(player_sid);
vision.player_visible = true;
let npc = world
.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(npc);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
assert!(!queue.is_empty(), "should emit DirectObservation for player");
let event = &queue.events[0];
assert_eq!(event.observer, npc);
assert!(matches!(
event.event_type,
KnowledgeEventType::DirectObservation { target, .. } if target == player
));
}
#[test]
fn npc_losing_player_emits_left_los() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
let player_sid = registry.register(player);
// NPC that had Direct knowledge of player (player was just visible)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(player_sid, pos(16, 14), 50);
// Memory with last known position
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(16, 14),
observed_tick: 50,
zone_inference: None,
},
);
// Vision state: player NOT visible now
let vision = NpcVisionState::default();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
.id();
registry.register(npc);
world.insert_resource(registry);
let mut time = SimulationTime::default();
time.tick = 60;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
let has_left_los = queue.events.iter().any(|e| {
matches!(
e.event_type,
KnowledgeEventType::LeftLOS { target } if target == player
)
});
assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS");
// Check zone inference was created
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
let entry = npc_memory.last_known.get(&player_sid).unwrap();
assert!(
entry.zone_inference.is_some(),
"zone inference should be created when player leaves LOS"
);
assert_eq!(
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
60 + INFERENCE_DEGRADE_TICKS
);
}
#[test]
fn npc_updates_memory_on_player_observation() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
let player_sid = registry.register(player);
let mut vision = NpcVisionState::default();
vision.visible_entities.insert(player_sid);
vision.player_visible = true;
let npc = world
.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(npc);
let mut time = SimulationTime::default();
time.tick = 100;
world.insert_resource(time);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let memory = world.get::<NpcMemory>(npc).unwrap();
let entry = memory.last_known.get(&player_sid).unwrap();
assert_eq!(entry.position, pos(10, 10));
assert_eq!(entry.observed_tick, 100);
assert!(entry.zone_inference.is_none(), "active observation = no inference");
}
// --- degrade_npc_inferences tests ---
#[test]
fn inference_degrades_after_threshold() {
let mut world = setup_world(32, 32);
let player_sid = StableId(1);
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(10, 10),
observed_tick: 50,
zone_inference: Some(ZoneInference {
last_seen_position: pos(10, 10),
observed_tick: 50,
degrades_at_tick: 100,
}),
},
);
let npc = world.spawn((Npc, ActiveSim, memory)).id();
// Tick 90 (before degradation, on minute boundary)
let mut time = SimulationTime::default();
time.tick = 90;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(degrade_npc_inferences);
schedule.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_some(),
"inference should survive before degradation tick"
);
// Tick 100 (degradation tick, on minute boundary)
let mut time = SimulationTime::default();
time.tick = 100;
world.insert_resource(time);
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
schedule2.add_systems(degrade_npc_inferences);
schedule2.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_none(),
"inference should be removed at degradation tick"
);
}
#[test]
fn inference_degradation_skips_non_minute_ticks() {
let mut world = setup_world(32, 32);
let player_sid = StableId(1);
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(10, 10),
observed_tick: 50,
zone_inference: Some(ZoneInference {
last_seen_position: pos(10, 10),
observed_tick: 50,
degrades_at_tick: 55, // already past
}),
},
);
let npc = world.spawn((Npc, ActiveSim, memory)).id();
// Tick 57 — past degradation but NOT a minute boundary
let mut time = SimulationTime::default();
time.tick = 57;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(degrade_npc_inferences);
schedule.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_some(),
"degradation should not run on non-minute ticks"
);
}
#[test]
fn no_events_when_no_player_entity() {
let mut world = setup_world(32, 32);
let registry = EntityRegistry::new(0);
let vision = NpcVisionState::default();
world.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
));
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world); // should not panic
let queue = world.resource::<KnowledgeEventQueue>();
assert!(queue.is_empty(), "no events when no player entity exists");
}
}
+9
View File
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::examine::ExamineResultBuffer;
use crate::simulation::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
@@ -83,6 +84,7 @@ pub fn compute_observer_snapshot(
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
Option<&mut ExamineResultBuffer>,
),
With<PlayerCharacter>,
>,
@@ -98,6 +100,7 @@ pub fn compute_observer_snapshot(
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
let Ok((
observer_entity,
@@ -114,6 +117,7 @@ pub fn compute_observer_snapshot(
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
mut examine_result_buffer_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -199,6 +203,7 @@ pub fn compute_observer_snapshot(
let current_monologue = monologue_buffer.take();
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take());
let scan_events = scan_event_buffer_opt
.as_mut()
.map(|buf| buf.take())
@@ -391,6 +396,10 @@ pub fn compute_observer_snapshot(
conversation_events,
conversation_ended,
follow_state,
examine_result,
character_pressure: pressure_query.iter().next().map(|p| {
crate::simulation::pressure::CharacterPressureWire::from(p)
}),
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
poi_list,
+166
View File
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
);
}
}
// ---------------------------------------------------------------------------
// #337 — Tell state → snapshot integration (D-024 tell system)
// ---------------------------------------------------------------------------
/// Helper: run derive_tell_state + two-stage observer pipeline together.
fn run_tell_plus_observer_pipeline(world: &mut World) {
use crate::npc::tell_state::derive_tell_state;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
derive_tell_state,
compute_visibility_geometry.after(derive_tell_state),
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(derive_tell_state),
));
schedule.run(world);
}
#[test]
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
// → compute_observer_snapshot → VisibleEntity.tell_state.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::{DerivedTellState, TellCategory};
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "criminal record".into(),
severity: SecretSeverity::Major,
known_by: vec![],
},
ToleranceThreshold { current_stress: 60, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
Some(TellCategory::Nervous),
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
);
}
#[test]
fn tell_state_none_for_neutral_npc_in_snapshot() {
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
// Verifies the pipeline correctly omits tell when no conditions are met.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "minor embarrassment".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold { current_stress: 10, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"neutral NPC should have no tell state in snapshot"
);
}
#[test]
fn tell_state_none_when_npc_has_no_derived_tell_component() {
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC with no DerivedTellState component at all
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"NPC without DerivedTellState component should have tell_state = None"
);
}
+374
View File
@@ -0,0 +1,374 @@
//! Examine interaction system (#242).
//!
//! Handles the Examine verb: player examines an NPC or object at close range,
//! generating character-filtered observation text and a DirectObservation
//! KnowledgeGraph entry.
//!
//! Pipeline:
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
//! → process_player_input inserts ExamineRequest on player
//! → process_examine_interaction reads request, generates text, pushes KG event
//! → ExamineResultBuffer consumed by compute_observer_snapshot
//! → ObserverSnapshot.examine_result delivered to client
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::bridge::types::CharacterArchetype;
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
use crate::knowledge::EntityRegistry;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
use crate::simulation::interaction::CLOSE_RANGE;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Marker: player requested Examine interaction with a target entity this tick.
///
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
/// "Examine Object", or "ExamineObject". Consumed and removed by
/// process_examine_interaction each tick.
#[derive(Component, Debug)]
pub struct ExamineRequest {
pub target: Entity,
}
/// Character-filtered examination result for snapshot delivery.
///
/// Content differs per CharacterArchetype:
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExamineResultEvent {
/// Character-filtered observation text for client display.
pub text: String,
/// Wire-format entity identifier of the examined entity.
pub target_entity_id: u64,
}
/// Buffer holding the examine result for snapshot inclusion.
///
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
/// Attach to the player entity alongside other buffer components.
#[derive(Component, Debug, Default)]
pub struct ExamineResultBuffer {
pub(crate) result: Option<ExamineResultEvent>,
}
impl ExamineResultBuffer {
/// Drain and return the examine result, leaving the buffer empty.
pub fn take(&mut self) -> Option<ExamineResultEvent> {
self.result.take()
}
}
// ---------------------------------------------------------------------------
// Text generation (deterministic, integer-only — D-010)
// ---------------------------------------------------------------------------
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
/// Uses integer multiplication to avoid division by zero.
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
if threshold.threshold <= 0 {
return 0;
}
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
as u8
}
/// Map NpcMood to a terse descriptor shared by both archetypes.
fn mood_word(mood: NpcMood) -> &'static str {
match mood {
NpcMood::Neutral => "neutral",
NpcMood::Anxious => "anxious",
NpcMood::Frustrated => "frustrated",
NpcMood::Content => "at ease",
NpcMood::Suspicious => "watchful",
NpcMood::Warm => "open",
NpcMood::Hostile => "hostile",
NpcMood::Focused => "focused",
}
}
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
}
/// Generate character-filtered examination text from NPC component state.
/// All logic is pure, deterministic, and integer-based (D-010).
pub fn generate_examine_text(
mood: NpcMood,
ratio: u8,
archetype: CharacterArchetype,
traits_opt: Option<&PersonalityTraits>,
) -> String {
let stress_label = match ratio {
0..=30 => "relaxed",
31..=60 => "tense",
61..=85 => "stressed",
_ => "near breaking point",
};
let mood_label = mood_word(mood);
match archetype {
CharacterArchetype::Smuggler => {
// Physical threat read + cargo opportunity window
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
"Threat posture. Don't push it."
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
"Confident bearing. Will push back if cornered."
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
"Nervous type. Predictable under pressure."
} else {
"No obvious threat read."
};
let window = if ratio > 60 {
"Too distracted to track cargo movement."
} else if matches!(mood, NpcMood::Focused) {
"Paying close attention to this section."
} else {
"Standard patrol pattern. Window is there."
};
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
}
CharacterArchetype::Detective => {
// Procedural tells + behavioral read
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
"Controlled affect — practiced concealment."
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
"Involuntary stress markers present."
} else if matches!(mood, NpcMood::Suspicious) {
"Scanning. Aware of being observed."
} else {
"Baseline presentation."
};
let read = if ratio > 60 {
"Under pressure — potential liability or asset."
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
"Comfortable. Less guarded than usual."
} else {
"Routine behavior pattern."
};
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
}
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Process examine interaction: generate character-filtered observation text,
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
pub fn process_examine_interaction(
mut commands: Commands,
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut kg_events: ResMut<KnowledgeEventQueue>,
mut player_query: Query<
(
Entity,
&TilePosition,
&ExamineRequest,
Option<&CharacterArchetype>,
&mut ExamineResultBuffer,
),
With<PlayerCharacter>,
>,
npc_query: Query<(
&TilePosition,
Option<&MoodState>,
Option<&ToleranceThreshold>,
Option<&PersonalityTraits>,
)>,
) {
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
player_query.single_mut()
else {
return;
};
let target = examine_req.target;
let archetype = archetype_opt.copied().unwrap_or_default();
// Range check — examine requires close range (same as Talk/Confront)
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
tracing::warn!(?target, "process_examine_interaction: target not in query");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
};
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: target out of range (max {})",
CLOSE_RANGE
);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
// Push DirectObservation to KnowledgeEventQueue
kg_events.push(KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target,
position: *target_pos,
},
});
// Resolve target wire ID for snapshot
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(
target_entity_id,
"Examine: DirectObservation pushed, result written to buffer"
);
commands.entity(player_entity).remove::<ExamineRequest>();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::PersonalityTrait;
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
PersonalityTraits { traits: t.to_vec() }
}
#[test]
fn smuggler_hostile_npc_gives_threat_read() {
let text = generate_examine_text(
NpcMood::Hostile,
20,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
}
#[test]
fn smuggler_focused_npc_notes_attention() {
let text = generate_examine_text(
NpcMood::Focused,
30,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("close attention"), "expected attention note, got: {text}");
}
#[test]
fn smuggler_high_stress_identifies_distraction() {
let text = generate_examine_text(
NpcMood::Anxious,
80,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
}
#[test]
fn detective_deceptive_npc_notes_concealment() {
let t = traits(&[PersonalityTrait::Deceptive]);
let text = generate_examine_text(
NpcMood::Neutral,
20,
CharacterArchetype::Detective,
Some(&t),
);
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
}
#[test]
fn detective_anxious_npc_notes_stress_markers() {
let text = generate_examine_text(
NpcMood::Anxious,
50,
CharacterArchetype::Detective,
None,
);
assert!(
text.contains("stress markers"),
"expected stress markers, got: {text}"
);
}
#[test]
fn detective_content_npc_notes_low_guard() {
let text = generate_examine_text(
NpcMood::Content,
10,
CharacterArchetype::Detective,
None,
);
assert!(
text.contains("Less guarded"),
"expected low guard note, got: {text}"
);
}
#[test]
fn stress_ratio_zero_when_threshold_zero() {
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
assert_eq!(stress_ratio(&t), 0);
}
#[test]
fn stress_ratio_clamped_at_100() {
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
assert_eq!(stress_ratio(&t), 100);
}
#[test]
fn stress_ratio_negative_stress_is_zero() {
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
assert_eq!(stress_ratio(&t), 0);
}
#[test]
fn examine_result_buffer_take_drains() {
let mut buf = ExamineResultBuffer::default();
assert!(buf.take().is_none());
buf.result = Some(ExamineResultEvent {
text: "Test".into(),
target_entity_id: 42,
});
assert!(buf.take().is_some());
assert!(buf.take().is_none()); // idempotent drain
}
}
+69
View File
@@ -221,6 +221,16 @@ pub fn process_player_input(
current_tick,
);
}
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
| Some("ExamineObject") | Some("Observe") => {
handle_examine(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
@@ -471,6 +481,65 @@ fn handle_talk(
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
/// Examine is available at close range (same as Talk). Range check here matches
/// the server-side guard in process_examine_interaction.
#[allow(clippy::type_complexity)]
fn handle_examine(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut crate::simulation::stance::Stance>,
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Examine verb without target_entity_id");
return;
};
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Examine: target entity not in registry");
return;
};
// Server-side range check: reject Examine if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Examine: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::examine::ExamineRequest {
target: target_entity,
});
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
}
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
///
+10
View File
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod examine;
pub mod follow;
pub mod input;
pub mod interaction;
@@ -19,7 +20,9 @@ pub mod path_follow;
pub mod pathfinding;
pub mod poi;
pub mod poi_discovery;
pub mod pressure;
pub mod rng;
pub mod save_state;
pub mod sound;
pub mod spatial;
pub mod stance;
@@ -78,6 +81,13 @@ impl Plugin for SimulationPlugin {
poi_discovery::discover_pois
.after(crate::perception::observer::compute_visibility_geometry)
.before(crate::perception::observer::compute_observer_snapshot),
examine::process_examine_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
// Character pressure (#248) — reads NPC awareness + relationship graph
pressure::update_character_pressure
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+600
View File
@@ -0,0 +1,600 @@
//! Character goal/pressure framework (#248).
//!
//! Defines systemic pressures on the player character that modulate monologue
//! salience and observation priority. Not scripted arcs — emergent from
//! interaction of existing D-024 axes.
//!
//! ## Three pressure axes
//!
//! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness)
//! - **Relationship**: rises when NPCs distrust the player (negative trust in
//! `RelationshipGraph`)
//! - **Institutional**: detective-specific pressure (stub in v0.1)
//!
//! ## Update frequency
//!
//! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid
//! per-tick overhead of relationship graph scans.
//!
//! ## Output surface
//!
//! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD
//! - `pressure_mood()` helper for monologue salience weighting — high pressure
//! biases toward anxiety-tagged lines (D-035 mood tags)
//!
//! All arithmetic is integer-only (D-010 determinism).
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::awareness::PlayerAwareness;
use crate::npc::Npc;
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031).
pub const PRESSURE_UPDATE_INTERVAL: u64 = 10;
/// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure.
pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10;
/// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure.
pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15;
/// Pressure threshold above which monologue mood shifts to "anxious".
pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50;
/// Pressure threshold above which monologue mood shifts to "frustrated".
/// Below anxious threshold but above this → frustrated.
pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30;
/// Trust value at or below which a relationship counts as "hostile" for
/// relationship pressure.
pub const HOSTILE_TRUST_THRESHOLD: i8 = -3;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// Systemic pressure on the player character (#248).
///
/// Attached to the player entity. Updated every game-minute by
/// `update_character_pressure`. Feeds into monologue salience weighting
/// and ObserverSnapshot HUD data.
///
/// All values are 0–100, integer for D-010 determinism.
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct CharacterPressure {
/// Exposure pressure: rises when NPCs notice the player watching them.
/// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs.
pub exposure: i32,
/// Institutional pressure: detective-specific systemic pressure.
/// Stub in v0.1 — future sprints wire this to detective interaction patterns.
pub institutional: i32,
/// Relationship pressure: rises when NPCs distrust the player.
/// Derived from negative trust edges in the `RelationshipGraph`.
pub relationship: i32,
}
impl CharacterPressure {
/// Total pressure as a simple average of all axes (0–100).
///
/// Uses integer division — remainders are floor-truncated (D-010, no floats).
/// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67).
pub fn total(&self) -> i32 {
// Simple average, clamped. Integer division truncates toward zero (D-010).
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
}
/// Dominant mood tag for monologue salience weighting.
///
/// Returns the D-035 mood tag that should be preferred when selecting
/// monologue lines. `None` when pressure is low — baseline monologue
/// selection applies.
pub fn pressure_mood(&self) -> Option<&'static str> {
let total = self.total();
if total >= MOOD_ANXIOUS_THRESHOLD {
Some("anxious")
} else if total >= MOOD_FRUSTRATED_THRESHOLD {
Some("frustrated")
} else {
None
}
}
}
// ---------------------------------------------------------------------------
// Wire type for ObserverSnapshot
// ---------------------------------------------------------------------------
/// Character pressure data for client HUD display (#248).
///
/// Included in `ObserverSnapshot` when pressure is non-zero.
/// Client renders as a tension/pressure indicator widget.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterPressureWire {
/// Exposure pressure (0–100).
pub exposure: i32,
/// Institutional pressure (0–100).
pub institutional: i32,
/// Relationship pressure (0–100).
pub relationship: i32,
/// Total pressure (0–100).
pub total: i32,
/// Dominant mood tag, if any.
pub mood: Option<String>,
}
impl From<&CharacterPressure> for CharacterPressureWire {
fn from(p: &CharacterPressure) -> Self {
Self {
exposure: p.exposure,
institutional: p.institutional,
relationship: p.relationship,
total: p.total(),
mood: p.pressure_mood().map(String::from),
}
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Update character pressure from NPC awareness and relationship state.
///
/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute).
///
/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by
/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100
/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the
/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE`
/// - **Institutional**: stub (0) in v0.1
pub fn update_character_pressure(
time: Res<SimulationTime>,
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
registry: Res<crate::knowledge::EntityRegistry>,
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
) {
// Only run on interval ticks
if time.tick % PRESSURE_UPDATE_INTERVAL != 0 {
return;
}
let Ok((player_entity, mut pressure)) = player_query.single_mut() else {
return;
};
// --- Exposure pressure ---
let suspicious_count = awareness_query
.iter()
.filter(|a| a.suspicion_level > 0)
.count() as i32;
pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100);
// --- Relationship pressure ---
let player_stable = registry.to_stable(player_entity);
if let Some(player_sid) = player_stable {
// O(N) over all relationship edges — called once per game-minute, not every tick.
// Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations).
let hostile_edges = relationship_graph
.who_knows_full_scan(&player_sid)
.iter()
.filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD)
.count() as i32;
pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100);
}
// --- Institutional pressure ---
// Stub: v0.1 has no detective-specific interaction patterns yet.
// Future sprints wire this to game-time progression, investigation progress,
// and institutional NPC interactions.
// pressure.institutional stays at whatever it was (default 0).
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::EntityRegistry;
use crate::npc::awareness::PlayerAwareness;
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
use crate::npc::{Npc, RelationshipKind};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<EntityRegistry>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_character_pressure);
schedule.run(world);
}
// -----------------------------------------------------------------------
// Update interval gating
// -----------------------------------------------------------------------
#[test]
fn skips_non_interval_ticks() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 13; // not on interval
world.spawn((
Npc,
ActiveSim,
PlayerAwareness {
suspicion_level: 50,
..Default::default()
},
));
world.spawn((PlayerCharacter, CharacterPressure::default()));
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(pressure.exposure, 0, "should not update on non-interval tick");
}
#[test]
fn runs_on_interval_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10; // on interval
world.spawn((
Npc,
ActiveSim,
PlayerAwareness {
suspicion_level: 50,
..Default::default()
},
));
world.spawn((PlayerCharacter, CharacterPressure::default()));
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(
pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC,
"should update on interval tick"
);
}
// -----------------------------------------------------------------------
// Exposure pressure
// -----------------------------------------------------------------------
#[test]
fn exposure_from_suspicious_npcs() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
// 3 suspicious NPCs
for _ in 0..3 {
world.spawn((
Npc,
ActiveSim,
PlayerAwareness {
suspicion_level: 10,
..Default::default()
},
));
}
// 2 non-suspicious NPCs
for _ in 0..2 {
world.spawn((
Npc,
ActiveSim,
PlayerAwareness::default(),
));
}
world.spawn((PlayerCharacter, CharacterPressure::default()));
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(
pressure.exposure,
3 * EXPOSURE_PER_SUSPICIOUS_NPC,
"3 suspicious NPCs × {} per NPC",
EXPOSURE_PER_SUSPICIOUS_NPC
);
}
#[test]
fn exposure_caps_at_100() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
// 20 suspicious NPCs — would be 200, should cap at 100
for _ in 0..20 {
world.spawn((
Npc,
ActiveSim,
PlayerAwareness {
suspicion_level: 50,
..Default::default()
},
));
}
world.spawn((PlayerCharacter, CharacterPressure::default()));
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(pressure.exposure, 100, "exposure should cap at 100");
}
#[test]
fn no_suspicious_npcs_zero_exposure() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
world.spawn((Npc, ActiveSim, PlayerAwareness::default()));
world.spawn((PlayerCharacter, CharacterPressure::default()));
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(pressure.exposure, 0);
}
// -----------------------------------------------------------------------
// Relationship pressure
// -----------------------------------------------------------------------
#[test]
fn relationship_pressure_from_hostile_edges() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((PlayerCharacter, CharacterPressure::default()))
.id();
let player_sid = registry.register(player);
// Two NPCs with hostile trust toward the player
let npc1 = world.spawn(Npc).id();
let npc1_sid = registry.register(npc1);
let npc2 = world.spawn(Npc).id();
let npc2_sid = registry.register(npc2);
let mut graph = RelationshipGraph::new();
graph.set_relationship(
npc1_sid,
player_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: -5, // hostile
history: vec![],
last_interaction_tick: 0,
},
);
graph.set_relationship(
npc2_sid,
player_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: -4, // hostile
history: vec![],
last_interaction_tick: 0,
},
);
world.insert_resource(registry);
world.insert_resource(graph);
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(
pressure.relationship,
2 * RELATIONSHIP_PER_HOSTILE_EDGE,
"2 hostile edges × {} per edge",
RELATIONSHIP_PER_HOSTILE_EDGE
);
}
#[test]
fn neutral_trust_no_relationship_pressure() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((PlayerCharacter, CharacterPressure::default()))
.id();
let player_sid = registry.register(player);
let npc = world.spawn(Npc).id();
let npc_sid = registry.register(npc);
let mut graph = RelationshipGraph::new();
graph.set_relationship(
npc_sid,
player_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 0, // neutral
history: vec![],
last_interaction_tick: 0,
},
);
world.insert_resource(registry);
world.insert_resource(graph);
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(pressure.relationship, 0);
}
#[test]
fn trust_at_boundary_not_hostile() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((PlayerCharacter, CharacterPressure::default()))
.id();
let player_sid = registry.register(player);
let npc = world.spawn(Npc).id();
let npc_sid = registry.register(npc);
let mut graph = RelationshipGraph::new();
graph.set_relationship(
npc_sid,
player_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: -2, // above hostile threshold (-3)
history: vec![],
last_interaction_tick: 0,
},
);
world.insert_resource(registry);
world.insert_resource(graph);
run_system(&mut world);
let mut q = world.query::<&CharacterPressure>();
let pressure = q.single(&world).unwrap();
assert_eq!(
pressure.relationship, 0,
"trust -2 should not count as hostile (threshold is -3)"
);
}
// -----------------------------------------------------------------------
// Total + mood
// -----------------------------------------------------------------------
#[test]
fn total_is_average_clamped() {
let p = CharacterPressure {
exposure: 60,
institutional: 30,
relationship: 45,
};
// (60 + 30 + 45) / 3 = 45
assert_eq!(p.total(), 45);
}
#[test]
fn total_does_not_go_below_zero() {
let p = CharacterPressure {
exposure: 0,
institutional: 0,
relationship: 0,
};
assert_eq!(p.total(), 0);
}
#[test]
fn pressure_mood_anxious() {
let p = CharacterPressure {
exposure: 100,
institutional: 100,
relationship: 100,
};
// total = 100, >= 50 → anxious
assert_eq!(p.pressure_mood(), Some("anxious"));
}
#[test]
fn pressure_mood_frustrated() {
let p = CharacterPressure {
exposure: 50,
institutional: 50,
relationship: 20,
};
// total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated
assert_eq!(p.pressure_mood(), Some("frustrated"));
}
#[test]
fn pressure_mood_none_when_low() {
let p = CharacterPressure {
exposure: 10,
institutional: 0,
relationship: 10,
};
// total = (10+0+10)/3 = 6, < 30 → None
assert_eq!(p.pressure_mood(), None);
}
// -----------------------------------------------------------------------
// Wire roundtrip
// -----------------------------------------------------------------------
#[test]
fn wire_roundtrip() {
let p = CharacterPressure {
exposure: 30,
institutional: 10,
relationship: 50,
};
let wire = CharacterPressureWire::from(&p);
let json = serde_json::to_string(&wire).expect("should serialize");
let decoded: CharacterPressureWire =
serde_json::from_str(&json).expect("should deserialize");
assert_eq!(decoded.exposure, 30);
assert_eq!(decoded.institutional, 10);
assert_eq!(decoded.relationship, 50);
assert_eq!(decoded.total, p.total());
assert_eq!(decoded.mood, p.pressure_mood().map(String::from));
}
// -----------------------------------------------------------------------
// No player entity — no panic
// -----------------------------------------------------------------------
#[test]
fn no_player_no_panic() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
run_system(&mut world); // should not panic
}
// -----------------------------------------------------------------------
// Constant value assertions (#248 spec compliance)
// -----------------------------------------------------------------------
#[test]
fn pressure_constants_have_expected_values() {
assert_eq!(
PRESSURE_UPDATE_INTERVAL, 10,
"#248: pressure updates every 10 ticks (1 game-minute, D-031)"
);
assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10);
assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15);
assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50);
assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30);
assert_eq!(HOSTILE_TRUST_THRESHOLD, -3);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! Save state data model (#256, D-010).
//!
//! `SaveStateV1` is the versioned serialization envelope for full game state.
//! Shares architecture with #96 (state serialization system) — this module
//! defines the data model only; ECS extraction and injection is #257.
//!
//! ## Write format: MessagePack
//!
//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020).
//! Both the IPC protocol and save files use the same codec for simplicity.
//! RON/YAML alternatives were considered — MessagePack chosen for consistency.
//! Human-readable debug output can be derived via the Debug impl or a separate
//! conversion step; a full RON bridge is deferred beyond v0.1.
//!
//! ## Versioning strategy
//!
//! `format_version: u8` bumps on breaking schema changes. Loader checks version
//! and rejects incompatible saves. `serde(default)` on optional new fields allows
//! forward-compatible extensions within the same major version.
//!
//! ## What is captured (v0.1 scope)
//!
//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction
//! - RNG seed: reproduce the same random sequence on load (D-010)
//! - Player knowledge graph: the observer's epistemics at save time
//! - Global relationship graph: the NPC social web (resource, not per-entity)
//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue
//!
//! ## Not yet captured (deferred to #257 and beyond)
//!
//! - Full ECS world extraction/injection (system not yet written)
//! - Pathfinding state (reconstructed from position + routine)
//! - Tier transitions in-flight (dropped to background state on load)
//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after
//! reload; memory degrades naturally over time so reset-on-load is acceptable)
use serde::{Deserialize, Serialize};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::StableId;
use crate::npc::{SecretSeverity, Relationships};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
/// Current format version. Bump on any breaking schema change.
pub const SAVE_FORMAT_VERSION: u8 = 1;
/// Top-level save state envelope (#256, D-010).
///
/// Serialized with MessagePack (rmp_serde) for storage. Load with
/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`.
///
/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION`
/// before trusting content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveStateV1 {
/// Format version. Must equal `SAVE_FORMAT_VERSION` on load.
pub format_version: u8,
/// Simulation tick at the moment of save.
pub tick: u64,
/// Active tick rate at save time (Full/Half/Paused).
pub tick_rate: TickRate,
/// RNG seed active at save time for deterministic replay (D-010).
/// On load, seed the RNG from this value before advancing any ticks.
pub seed: u64,
/// Player character knowledge graph — the observer's epistemics at save time.
pub player_knowledge: KnowledgeGraph,
/// Global NPC relationship graph resource.
/// Serialized as a unit: all directed edges between NPCs and player.
pub relationship_graph: RelationshipGraph,
/// Per-NPC summary state for each simulated NPC.
/// Order is deterministic (sorted by stable_id in ascending order).
pub npc_states: Vec<NpcSaveState>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
///
/// Captures the D-024 axis values and position. On load, the full NPC entity
/// is reconstructed by injecting these values into the appropriate components.
/// Field order matches the 10-axis model (D-024) for readability.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcSaveState {
/// Stable entity identifier (survives serialization — D-020).
pub stable_id: StableId,
/// Last known tile position.
pub position: TilePosition,
// Axis 2: Secret severity (description is regenerated from content on load)
pub secret_severity: SecretSeverity,
// Axis 3: Per-NPC relationship slots
pub relationships: Option<Relationships>,
// Axis 4: Tolerance — current stress level at save time
pub current_stress: i16,
/// Tolerance threshold value (does not change at runtime).
pub tolerance_threshold: i16,
// Axis 7: Contentment
pub contentment: i16,
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
pub knowledge_graph: Option<KnowledgeGraph>,
}
impl SaveStateV1 {
/// Serialize to MessagePack bytes.
pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
/// Deserialize from MessagePack bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
rmp_serde::from_slice(bytes)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
fn minimal_save_state() -> SaveStateV1 {
SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 0,
tick_rate: TickRate::Full,
seed: 42,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
}
}
// -----------------------------------------------------------------------
// Roundtrip tests: serialize → deserialize → re-serialize → bytes match
// -----------------------------------------------------------------------
#[test]
fn empty_save_state_roundtrips() {
// Spec (#256): roundtrip must produce identical state.
// Strategy: bytes(original) == bytes(roundtrip(original))
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly");
}
#[test]
fn format_version_preserved_in_roundtrip() {
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.format_version, SAVE_FORMAT_VERSION,
"format version must survive roundtrip"
);
}
#[test]
fn tick_and_seed_preserved_in_roundtrip() {
let mut state = minimal_save_state();
state.tick = 12345;
state.seed = 0xDEADBEEF;
state.tick_rate = TickRate::Half;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.tick, 12345);
assert_eq!(recovered.seed, 0xDEADBEEF);
assert_eq!(recovered.tick_rate, TickRate::Half);
}
#[test]
fn npc_states_roundtrip_with_position_and_axes() {
// Spec (#256): per-NPC D-024 axis values must survive serialization.
let mut state = minimal_save_state();
state.npc_states = vec![
NpcSaveState {
stable_id: StableId(101),
position: TilePosition::new(10, 20, 0),
secret_severity: crate::npc::SecretSeverity::Major,
relationships: None,
current_stress: 45,
tolerance_threshold: 80,
contentment: -15,
knowledge_graph: None,
},
NpcSaveState {
stable_id: StableId(202),
position: TilePosition::new(5, 5, 1),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 60,
contentment: 30,
knowledge_graph: None,
},
];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.npc_states.len(), 2);
let npc1 = &recovered.npc_states[0];
assert_eq!(npc1.stable_id, StableId(101));
assert_eq!(npc1.position, TilePosition::new(10, 20, 0));
assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major);
assert_eq!(npc1.current_stress, 45);
assert_eq!(npc1.tolerance_threshold, 80);
assert_eq!(npc1.contentment, -15);
let npc2 = &recovered.npc_states[1];
assert_eq!(npc2.stable_id, StableId(202));
assert_eq!(npc2.contentment, 30);
}
#[test]
fn player_knowledge_graph_roundtrips() {
// Spec (#256): KnowledgeGraph is "already serializable" — verify it
// survives a save state roundtrip intact.
use crate::knowledge::types::{KnowledgeSource, KnowledgeState};
use crate::simulation::movement::TilePosition;
let mut state = minimal_save_state();
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5);
// Add a fact
kg.facts.insert(
FactId("contraband.ring_exists".into()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 100,
disclosure_blocked: false,
},
);
state.player_knowledge = kg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
// Roundtrip the recovered state again — bytes must still match
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"KnowledgeGraph roundtrip must be idempotent"
);
}
#[test]
fn relationship_graph_roundtrips() {
use crate::npc::relationships::RelationshipEdge;
use crate::npc::RelationshipKind;
use crate::knowledge::types::StableId;
let mut state = minimal_save_state();
let mut rg = RelationshipGraph::new();
rg.set_relationship(
StableId(1),
StableId(2),
RelationshipEdge {
kind: RelationshipKind::Friend,
trust: 5,
history: vec![],
last_interaction_tick: 0,
},
);
state.relationship_graph = rg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
}
#[test]
fn npc_with_knowledge_graph_roundtrips() {
// Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip.
let mut state = minimal_save_state();
let mut npc_kg = KnowledgeGraph::new();
npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10);
state.npc_states = vec![NpcSaveState {
stable_id: StableId(1),
position: TilePosition::new(1, 1, 0),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 50,
contentment: 0,
knowledge_graph: Some(npc_kg),
}];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"NPC KnowledgeGraph roundtrip must be lossless"
);
}
#[test]
fn save_format_version_constant_is_one() {
// Document the version explicitly so CI catches unintentional bumps.
assert_eq!(SAVE_FORMAT_VERSION, 1);
}
}
+22
View File
@@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync {
/// Return all entities at the exact `position`.
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
/// Return all entities within Manhattan distance `radius` of `position`,
/// **including** entities exactly at `position`. Single-pass alternative to
/// `entities_in_range` + `entities_at`.
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
let mut result = self.entities_in_range(position, radius);
result.extend(self.entities_at(position));
result
}
/// Insert or update an entity's position in the index.
fn update(&mut self, entity: Entity, position: TilePosition);
@@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex {
.collect()
}
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
self.entries
.iter()
.filter(|(_, pos)| {
pos == position
|| pos
.manhattan_distance(position)
.is_some_and(|d| d <= radius)
})
.map(|(entity, _)| *entity)
.collect()
}
fn update(&mut self, entity: Entity, position: TilePosition) {
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
entry.1 = position;
+1
View File
@@ -183,6 +183,7 @@ pub fn setup_gauntlet(app: &mut App) {
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
crate::simulation::pressure::CharacterPressure::default(),
))
.id();
registry.register(player);
+2
View File
@@ -66,6 +66,8 @@ fn snapshot_roundtrip_over_unix_socket() {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
+2
View File
@@ -52,6 +52,8 @@ fn snapshot_roundtrip_over_tcp() {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
+4
View File
@@ -41,6 +41,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
@@ -230,6 +232,8 @@ fn generate_msgpack_fixtures() {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
@@ -2,6 +2,7 @@
"blocked_entities": [
2
],
"character_pressure": null,
"conversation_ended": [],
"conversation_events": [],
"current_monologue": null,
@@ -38,6 +39,7 @@
"z": 0
}
],
"examine_result": null,
"follow_state": null,
"game_time": {
"day": 0,
+7 -1
View File
@@ -30,6 +30,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
@@ -278,6 +280,8 @@ fn snapshot_v2_fields_roundtrip() {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
@@ -380,6 +384,8 @@ fn all_facing_direction_variants_roundtrip() {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
@@ -1388,7 +1394,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
// are all `#[serde(default)]` — they must default to None/empty when absent.
let minimal_json = serde_json::json!({
"version": 13,
"version": 14,
"tick": 42,
"game_time": {
"day": 0,
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Pre-commit check: detect duplicate decision IDs across decisions/*.md files.
# Fails if the same D-NNN, Q-NNN, or R-NNN appears in more than one file.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
# Use the decisions_sync.py check-dupes command
RESULT=$(python3 "$REPO_ROOT/db/connectors/decisions_sync.py" check-dupes 2>&1)
# Parse the JSON result
DUPES=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('duplicates',0))" 2>/dev/null || echo "0")
TOTAL=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total_ids',0))" 2>/dev/null || echo "0")
if [ "$DUPES" -gt 0 ]; then
echo "check-decision-ids: FAILED — $DUPES duplicate ID(s) found"
echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for detail in d.get('details', []):
print(f' {detail}')
" 2>/dev/null
exit 1
fi
echo "check-decision-ids: OK — $TOTAL unique IDs, no duplicates"
exit 0