diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d473c80..827385a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - 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/-/` 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) - +- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines) +- Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020) +- Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation +- State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026) +- Scope tag system — `ScopeTagKind` (Neighborhood, ActiveQuest, Colleague, KnownContact), `ScopePinned` marker, automatic assignment from KnowledgeGraph and RelationshipGraph (#98, D-026) +- Timestamp-based eviction — `LastInteractionTick` LRU tracking, `SimSpacePressure` resource, BinaryHeap eviction respecting scope-pinned entities, Active cap 80 (#97, D-026) +- Save/load ECS extraction — `save_to_file`/`load_from_file` via MessagePack, `SaveGame`/`LoadGame` IPC commands, `SaveLoadResultWire` on ObserverSnapshot (#553, D-085) +- ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks +- Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200) +- Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010) +- Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review +- D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme) +- Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology) +- Decision ID claim system — `db/connectors/decision` CLI with `next`, `claim`, `check-dupes` commands to prevent cross-worktree D/Q/R ID collisions, pre-commit duplicate check +- D-085: Per-game save directory structure — every new game creates `user://saves//`, F5 quicksave, F6 quickload +- Q-029: Save file format design — long-term considerations for versioning, compression, integrity, metadata headers +- D-086: Renumbered insert icon system (was D-084 on visual branch) to resolve cross-worktree ID collision +- Save/load wireframe updated for D-085 — LOAD tab shows games grouped by directory with expand/collapse, QUICKSAVE slot, F5/F6 hints +- Sprint 19: Persist planned — 16 tickets (server 7, client 5, CI 4) covering save/load, tier eviction/scope, test infrastructure +- Character creation & game setup workshop brief — covers creation model, seed boundary, gate activation, quest seeding, game toggles (resolves Q-011) - 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) diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 4cca59cca..61906917c 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -104,6 +104,9 @@ notifications: # System save_complete: "Progress saved." + load_complete: "Session restored." + save_failed: "Save failed." + load_failed: "Load failed." connection_lost: "Signal interrupted." connection_restored: "Signal restored." diff --git a/client/project.godot b/client/project.godot index eee056cf3..486c553c6 100644 --- a/client/project.godot +++ b/client/project.godot @@ -136,6 +136,16 @@ teleport_hub={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +quicksave={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +quickload={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index d96294fd2..a59174bfd 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -70,6 +70,11 @@ var insert_active: bool = true # Null in v0.1 (server does not yet send this field; protocol change required). var rng_seed: Variant = null +# v15 fields (#554, D-085): save/load result from server. +# {success: bool, kind: "save"|"load", error: Variant} or null. +# One-shot: consumed by main.gd after display, then set back to null. +var save_result: Variant = null + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] @@ -279,6 +284,12 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: current_examine_result = null + # v15: save_result (#554, D-085) — one-shot save/load confirmation from server. + if snapshot.has("save_result") and snapshot.save_result is Dictionary: + save_result = snapshot.save_result + else: + save_result = null + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 37957efd0..6128d4b4d 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -22,6 +22,8 @@ enum Action { OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only SET_FACING, # D-054: facing octant update (no movement) TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) + SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path + LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path } var input_queue: Array[Dictionary] = [] @@ -112,12 +114,24 @@ func _unhandled_input(event: InputEvent) -> void: elif event.is_action_pressed("teleport_hub"): if GameState.gauntlet_mode: action = Action.TELEPORT_HUB + elif event.is_action_pressed("quicksave"): + action = Action.SAVE_GAME + elif event.is_action_pressed("quickload"): + action = Action.LOAD_GAME if action != -1: - input_queue.append({ + var entry := { "action": action, "timestamp_msec": Time.get_ticks_msec(), - }) + } + # #554: Attach save path for SaveGame/LoadGame actions + if action == Action.SAVE_GAME or action == Action.LOAD_GAME: + var game_id := GameState.current_game_id + if game_id.is_empty(): + get_viewport().set_input_as_handled() + return # No active session — ignore save/load + entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"} + input_queue.append(entry) get_viewport().set_input_as_handled() diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd index da1ddade7..87bd3c90d 100644 --- a/client/scripts/autoloads/session_manager.gd +++ b/client/scripts/autoloads/session_manager.gd @@ -84,9 +84,24 @@ func quit_to_menu() -> void: 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 = "" + # #554: Trigger quicksave before navigating to menu. + # send_input() buffers the command — defer scene change by one frame so + # SimBridge._process() flushes the outbound buffer before teardown. + if not GameState.current_game_id.is_empty(): + var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav" + SimBridge.send_input({ + "action": InputMapper.Action.SAVE_GAME, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": {"path": path}, + }) + GameState.current_game_id = "" + _navigate_to_menu.call_deferred() + else: + GameState.current_game_id = "" + get_tree().change_scene_to_file(MENU_SCENE) + + +func _navigate_to_menu() -> void: get_tree().change_scene_to_file(MENU_SCENE) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 5e7b52283..6031d497a 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -289,6 +289,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: if old_conv_ended.size() > 0: var new_conv_ended: Array = snapshot.get("conversation_ended", []) snapshot["conversation_ended"] = old_conv_ended + new_conv_ended + # #554: Carry forward save/load result (one-shot, consumed by main.gd) + if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: + snapshot["save_result"] = _last_snapshot["save_result"] _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. @@ -326,6 +329,10 @@ static func action_enum_to_wire(action: int) -> String: return "SetFacing" # D-054: facing octant update (no movement) InputMapper.Action.TELEPORT_HUB: return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel) + InputMapper.Action.SAVE_GAME: + return "SaveGame" # #554: F5 quicksave (D-085) + InputMapper.Action.LOAD_GAME: + return "LoadGame" # #554: F6 quickload (D-085) _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 387ab937e..a30189d70 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -173,6 +173,9 @@ func _process(delta: float) -> void: _consume_conversation_ended() _consume_dialogue_response() + # #554: Show save/load result notification + _consume_save_result() + # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. # Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame. @@ -381,6 +384,27 @@ func _consume_dialogue_response() -> void: GameState.dialogue_response = null +# #554: Show save/load result notification from server response. +func _consume_save_result() -> void: + if GameState.save_result == null: + return + var result: Dictionary = GameState.save_result + GameState.save_result = null # consume once + var msg: String + if result.get("success", false): + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_complete") + else: + msg = UIStrings.get_text("notifications.load_complete") + else: + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_failed") + else: + msg = UIStrings.get_text("notifications.load_failed") + if monologue_display: + monologue_display.show_notification(msg) + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 49175d52f..0f00caf74 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 14 +const PROTOCOL_VERSION: int = 15 # -- Decode: bytes from server → GDScript types -------------------------------- @@ -233,6 +233,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "confidence": str(raw_examine.get("confidence", "KnowsOf")), } + # v15: save_result (#554, D-085) — one-shot save/load operation result. + # {success: bool, kind: "save"|"load", error: String|null} + var save_result: Variant = null + var raw_save: Variant = raw.get("save_result") + if raw_save is Dictionary: + save_result = { + "success": bool(raw_save.get("success", false)), + "kind": str(raw_save.get("kind", "")), + "error": raw_save.get("error"), + } + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}], # facts: [{fact_id, confidence, source, state, acquired_tick}]} @@ -290,6 +301,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "poi_list": poi_list, "examine_result": examine_result, "player_knowledge": player_knowledge, + "save_result": save_result, } diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd index b376322c3..d75b6f8cf 100644 --- a/client/scripts/protocol/test_harness.gd +++ b/client/scripts/protocol/test_harness.gd @@ -189,6 +189,7 @@ func snapshot() -> Dictionary: "gauntlet_mode": gauntlet_mode, "conversation_events": conv_events, "conversation_ended": conv_ended, + "save_result": null, } diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 148ce3bcd..47a9ea59e 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 53c6b6750..ffd3ba965 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 91f76e316..d275e542c 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 3c851d4c8..888250358 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 304069bcb..13547db7c 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 148ce3bcd..47a9ea59e 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 7b28637f4..64ffe56ad 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index a139d15d6..6e7cbb7fa 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 5cd49a1bb..1c6340e12 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 1ff2140fd..09def6a2c 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index f95b913a3..37a52844d 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = { } const _FALLBACK_STANDARD: Color = Color("#c8d0e0") const _FALLBACK_URGENT: Color = Color("#e0e8f8") +const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification +const _NOTIFICATION_DURATION: float = 2.5 @onready var _vbox: VBoxContainer = $VBoxContainer @@ -62,7 +64,10 @@ func _process(delta: float) -> void: var now := float(Time.get_ticks_msec()) if now >= _next_fade_in_msec: var next: Dictionary = _queue.pop_front() - _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) + if next.get("is_notification", false): + _show_notification_line(next.text) + else: + _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) # Display a monologue line. @@ -71,6 +76,26 @@ func _process(delta: float) -> void: # Empty text is silently ignored — no slot created, no queue entry. # lattice_profile is read from GameState here and passed down — renderer stays # decoupled from the autoload (D-020 renderer contract). +# #554: Show a brief system notification (save/load result, connection status). +# Uses neutral color, short duration, bypasses lattice_profile styling. +func show_notification(text: String) -> void: + if text.is_empty(): + return + var now := float(Time.get_ticks_msec()) + if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: + _show_notification_line(text) + else: + var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true} + if _queue.size() < MAX_QUEUE: + _queue.append(entry) + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + else: + var lowest := _lowest_priority_idx() + if 1 >= _queue[lowest].priority: + _queue[lowest] = entry + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + + func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: if text.is_empty(): return @@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: # Internal # --------------------------------------------------------------------------- +func _show_notification_line(text: String) -> void: + var container := MarginContainer.new() + container.add_theme_constant_override("margin_left", 4) + container.add_theme_constant_override("margin_right", 4) + container.add_theme_constant_override("margin_top", 2) + container.add_theme_constant_override("margin_bottom", 2) + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.fit_content = true + label.scroll_active = false + label.add_theme_font_size_override("normal_font_size", 13) + var safe_text := text.replace("[", "[lb]") + label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text] + container.add_child(label) + _vbox.add_child(container) + var slot := { + node = container, + expire_timer = _NOTIFICATION_DURATION, + priority = 1, + tween = null, + } + _visible.append(slot) + _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 + container.modulate.a = 0.0 + var tween := create_tween() + slot.tween = tween + tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC) + + func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void: var line_node := _build_line_node(text, is_urgent, lattice_profile) _vbox.add_child(line_node) diff --git a/decisions/README.md b/decisions/README.md index 47d527366..22e8ca8a3 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -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, D-085 | +| [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, D-088 | | [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 | +| [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, D-090, D-092 | +| [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, D-087, D-089, D-091 | | [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 | -| [questions.md](questions.md) | Open questions | Q-001 through Q-029 | +| [questions.md](questions.md) | Open questions | Q-001 through Q-039 | | [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 | ## Querying Decisions diff --git a/decisions/architecture.md b/decisions/architecture.md index 27d3d2754..722539d0e 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -223,6 +223,15 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Raised by:** Team Leader (Jeroen) - **Dissent:** None +### D-088: 3-state pause system — Normal/Overlay/Paused, server-authoritative +- **Date:** 2026-02-12 +- **Decision:** Simulation runs at three speed states: Normal (100% tick rate), Overlay (50% — active during knowledge panel, dialogue, map view), Paused (0% — full pause via Esc). Server is authoritative: client sends pause requests, server sets `sim_speed` field in ObserverSnapshot. Client reads `sim_speed` and adjusts presentation. No client-side tick manipulation. +- **Rationale:** Server-authoritative speed states preserve D-010 principle 4 (deterministic simulation). Client cannot modify simulation state directly. Overlay mode at 50% ensures UI interactions do not require a hard pause while still giving the player time to read and decide. +- **Raised by:** Tyre, Dudley +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, closing round resolution +- **Cross-reference:** D-031 (time system), D-020 (client-server architecture) + --- -*17 decisions. Last updated: 2026-02-25* +*18 decisions. Last updated: 2026-02-12 (D-088 added — retroactive filing from v0.1 Content Scoping Workshop)* diff --git a/decisions/content.md b/decisions/content.md index 21a0b9e17..11a8ff4a8 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -204,6 +204,27 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio - **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review. - **Dissent:** None. +### D-090: PC voice registers — smuggler and detective speech patterns +- **Date:** 2026-02-12 +- **Decision:** Each playable character has a defined voice register for monologue and dialogue: + - **Smuggler:** Feeling-first. Sentence fragments. Concrete/physical vocabulary. Notices bodies, spaces, exits. Emotional baseline: wary comfort. Lies by omission. Relationship to authority: avoidance. + - **Detective:** Analysis-first. Complete sentences. Institutional vocabulary. Notices patterns, inconsistencies, procedural gaps. Emotional baseline: professional detachment. Lies by reframing. Relationship to authority: representative. + These registers govern all authored content per character (monologue pools per D-032, dialogue access per D-028). +- **Rationale:** Register differences must be architectural, not incidental. Without defined registers, authors default toward a single generic voice and the dual-lens effect (D-027 criterion 2) collapses. The registers encode the characters' relationships to the world, not just vocabulary preferences. +- **Raised by:** Mellanie, Paula +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, Mellanie Round 2 synthesis +- **Cross-reference:** D-032 (separate monologue pools), D-034 (THE FRIEND pattern) + +### D-092: Anchor line requirement in NPC style guide +- **Date:** 2026-02-12 +- **Decision:** Every NPC at Tier 1 and Tier 2 depth must have anchor lines — signature phrases or verbal tics that make them instantly recognizable in text. Requirements: Tier 1 NPCs (THE FRIEND, key triangle members): minimum 2 anchor lines per arc phase. Tier 2 NPCs (triangle periphery): minimum 1 anchor line. Tier 3 NPCs (background): no anchor requirement, generic pool lines only. Anchor lines must be authored, never generated. +- **Rationale:** Anchor lines create the "I know that voice" moment on repeat encounters. Generation cannot produce this — generated lines are statistically average, not distinctively characteristic. The generation expansion pass (D-028) fills volume; anchor lines create identity. +- **Raised by:** Mellanie +- **Dissent:** None +- **Source:** Wiki Review Workshop, Mellanie Round 2 proposal, consensus C-19 +- **Cross-reference:** D-034 (THE FRIEND pattern), D-028 (dialogue architecture), D-023 (three-tier content model) + --- -*17 decisions. Last updated: 2026-02-25 (D-084 added — Q-028 resolution)* +*19 decisions. Last updated: 2026-02-12 (D-090, D-092 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)* diff --git a/decisions/questions.md b/decisions/questions.md index 72962c50d..3c9c91748 100644 --- a/decisions/questions.md +++ b/decisions/questions.md @@ -182,6 +182,66 @@ Tracked questions awaiting discussion or resolution. - **Assigned to:** Tyre, Dudley - **Source:** Team Leader directive (Sprint 19 planning) +### Q-030: Seed configuration schema +- **Status:** Open +- **Question:** What artifact records all randomizer decisions at game start? The wiki-review workshop proposed a `seed-state.yaml` capturing: world seed, character selection, pool draws (Tier 1 modules, FRIEND selection, contraband variant), template assignments, NPC trait rolls, triangle configurations, and entanglement pattern. Ticket #394 (seed configuration schema design) exists but the design is open. +- **Assigned to:** Tyre, Gestalt +- **Source:** Wiki Review Workshop + v0.1 Content Scoping Workshop + +### Q-031: Combined content style guide +- **Status:** Open +- **Question:** Should the project have a single combined content style guide merging Paula's tier templates, Mellanie's voice conventions, Gestalt's mechanical constraints, and Miri's regional guide? The wiki-review workshop proposed this as a deliverable but it was never authored. What format, who owns it, and does it block content authoring? +- **Assigned to:** Mellanie, Paula +- **Source:** Wiki Review Workshop R2 + +### Q-032: Cultural ingredients menu +- **Status:** Open +- **Question:** Should world generation use a 6-category cultural ingredients menu (Heritage Roots, Settlement Motivation, Economic Function, Philosophical Alignment, Corporate/Faction Presence, Drift Stage) where each culture is composed by selecting from ingredient lists? The lead approved the "ingredients menu" model over fixed cultural taxonomies. Full specification needed: category definitions, ingredient lists per category, composition rules, absence-as-signal mechanics. +- **Assigned to:** Miri, Gestalt +- **Source:** Wiki Review Workshop R4, lead interview + +### Q-033: Three-system NPC architecture +- **Status:** Open +- **Question:** Should NPCs be formally composed from 9 thematic patterns (FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY) x 6 functional motivations (HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC)? D-024 defines 10 axes + combat but predates this refined system. The wiki-review workshop produced a full composition matrix with drama ratings and forbidden combinations. Does this supersede D-024 or extend it? +- **Assigned to:** Gestalt, Paula +- **Source:** Wiki Review Workshop R4 + +### Q-034: PC archetypes +- **Status:** Open +- **Question:** Should the full game support 8 fluid PC archetypes (Smuggler, Detective, Engineer, Diplomat, Medic, Scholar, Soldier, Merchant) with transition mechanics where archetype shifts during play based on player behavior? The lead approved 8 archetypes with fluid transitions as a game mechanic. v0.1 ships smuggler + detective only (D-027). Full archetype spec, transition triggers, and "vulnerable window" mechanics are undesigned. NOTE: The character-creation-game-setup workshop (Q-011) will address this — coordinate. +- **Assigned to:** Nigel, Gestalt +- **Source:** Wiki Review Workshop R4, lead interview + +### Q-035: Sacred/Profane/Middle Kingdom framework +- **Status:** Open +- **Question:** Should all game systems map to a Sacred/Profane/Middle Kingdom architectural framework? The lead approved this model where Sacred = what the system protects, Profane = what threatens it, Middle Kingdom = where the player navigates. The wiki-review workshop produced a full mapping table covering information, social, economic, spatial, temporal, and narrative systems. Needs formal specification and validation against current architecture. +- **Assigned to:** Gore, Gestalt +- **Source:** Wiki Review Workshop R4, lead interview + +### Q-036: District skeleton as generator output +- **Status:** Open +- **Question:** For the 300-world model, should the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) be the atomic output unit of the world generator? D-025 defines social sites as the atomic template unit for hand-authoring. The generator model reframes the district as a composed output from ingredient inputs. How does this interact with D-025? +- **Assigned to:** Tyre, Gestalt +- **Source:** Wiki Review Workshop R4 + +### Q-037: Generator development pipeline +- **Status:** Open +- **Question:** Should content production follow a 6-phase generator pipeline (Ingredient Authoring, Template Authoring, Generator Development, Validation Development, Generation + Review, Hand-Elevation)? The wiki-review workshop proposed this as the production model for 300 worlds. SI mapped a release path (v0.1 hand-authored, v0.2-0.5 template expansion, v0.6-0.10 generator development, pre-v1.0 validation). Needs scope assessment and sprint planning integration. +- **Assigned to:** SI, Tyre +- **Source:** Wiki Review Workshop R4 + +### Q-038: Authored content estimate at 300-world scale +- **Status:** Open +- **Question:** What is the irreducible authored content volume for 300 worlds? The wiki-review workshop estimated ~1,600-2,800 hours of hand-authoring for generator inputs (ingredient definitions, template specifications, validation rules, hand-elevation passes). How does this compare to the 20-district hand-authoring model it replaced? Is this estimate still valid given subsequent architectural decisions? +- **Assigned to:** Mellanie, SI +- **Source:** Wiki Review Workshop R4 + +### Q-039: Gate topology generation +- **Status:** Open +- **Question:** How should the world generator produce gate (wormhole) network topology for 300 worlds? The wiki-review workshop proposed: gate connectivity = Sacred (what connects), which worlds connect = Profane (what separates), accessible world count = Middle Kingdom (where the player navigates). Small-world network properties, hub-and-spoke vs mesh topology, and Sacred/Profane constraints on gate placement are all unresolved. D-012 covers chunk-based map architecture but predates the 300-world model. +- **Assigned to:** Tyre, Nigel +- **Source:** Wiki Review Workshop R4 + --- -*29 questions (7 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-25 (Q-029 added)* +*39 questions (7 resolved, 1 partially resolved, 31 open). Last updated: 2026-02-25 (Q-030 through Q-039 added — retroactive filings from Wiki Review Workshop and v0.1 Content Scoping Workshop)* diff --git a/decisions/scope.md b/decisions/scope.md index f109a050f..74a4a31d4 100644 --- a/decisions/scope.md +++ b/decisions/scope.md @@ -178,6 +178,33 @@ What we're building: game concept, design pillars, prototype definition, map spe - **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation: SmallVec<3>), Dudley (server model: BTreeMap + info boundary) - **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with minimal implementation after lead directive. +### D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions +- **Date:** 2026-02-12 +- **Decision:** v0.1 vertical slice uses 5 relationship triangles. Three are active forks (T1: Kael-Smuggler-Ring, T2: Sera-Detective-Commission, T4: Drin-System-Ring) with branching outcomes driven by player observation. Two are passive tensions (T3: Naia-Kael-Hael, T5: Worried Partner background) that provide atmosphere and secondary discovery paths. Active forks require authored content per branch. Passive tensions are system-driven. +- **Rationale:** Three active forks are within v0.1 content authoring capacity. Passive tensions require no branching content — they enrich discovery space without multiplying authored lines. +- **Raised by:** Gestalt, Paula +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis +- **Cross-reference:** D-027 (vertical slice), D-034 (THE FRIEND pattern) + +### D-089: Self-contained triangle forks for v0.1, no cross-triangle cascade +- **Date:** 2026-02-12 +- **Decision:** In v0.1, each triangle fork resolves independently. No triangle outcome triggers escalation in another triangle. Cross-triangle cascade (storyteller-managed, where resolving T1 affects T2 pressure) is deferred to v0.2+. This keeps v0.1 content authoring manageable — each triangle is a self-contained narrative unit. +- **Rationale:** Cross-triangle cascade requires the storyteller to track inter-triangle state and authors to write contingent branches. Both are out of scope for v0.1. Self-contained triangles can be authored, tested, and validated independently. +- **Raised by:** Paula, Gestalt +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis +- **Cross-reference:** D-087 (triangle configuration), D-027 (vertical slice) + +### D-091: Complicity as named thematic core +- **Date:** 2026-02-12 +- **Decision:** The game's thematic identity is complicity — not conspiracy, not detection, not information asymmetry (which is the mechanical core per D-007). The player becomes complicit through observation: seeing something means choosing whether to act on it. The smuggler is complicit in the ring's operations. The detective is complicit in the institution's blindness. Both discover they are already entangled before they choose to be. This framing governs narrative design, wow moment emotional targets (D-039), and the Divergence Reveal (D-027 criterion 4). +- **Rationale:** "Complicity" names the emotional experience that information asymmetry produces. It distinguishes this game from pure detective games (you uncover truth) and pure action games (you do things). Here: you watch, and the watching implicates you. +- **Raised by:** Gore +- **Dissent:** None +- **Source:** Wiki Review Workshop, Gore Round 2 proposal, confirmed by lead interview +- **Cross-reference:** D-007 (five pillars), D-039 (wow moments), D-027 (vertical slice) + --- -*14 decisions (12 active, 2 superseded). Last updated: 2026-02-13* +*17 decisions (15 active, 2 superseded). Last updated: 2026-02-12 (D-087, D-089, D-091 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)* diff --git a/docs/workshops/character-creation-game-setup/workshop-brief.md b/docs/workshops/character-creation-game-setup/workshop-brief.md new file mode 100644 index 000000000..fc00f8116 --- /dev/null +++ b/docs/workshops/character-creation-game-setup/workshop-brief.md @@ -0,0 +1,375 @@ +# Workshop: Character Creation & Game Setup + +**Date:** 2026-02-25 +**Facilitator:** Team Leader (Jeroen) +**Participants:** Nigel (replayability), Paula (narrative), Gestalt (systems), Miri (worldbuilding), Tyre (architecture), Qatux (documenter) +**Status:** Not started + +--- + +## Purpose + +Define what the character creation / new game screen actually does. This +is the most fundamental unresolved design question in the project: what +does the player choose, what does the seed control, and what does that +combination produce? + +This workshop resolves **Q-011** (character selection and playable +characters) and establishes the boundary between player agency, character +archetype, and world seed. Everything downstream — quest generation, gate +activation, difficulty, replayability — flows from getting this table right. + +## Context + +### What's decided + +- **D-005:** Single character per playthrough. Character choice determines + starting location, starting knowledge, available levers, personal goals. + "Same conspiracy, different character, completely different game." +- **D-027:** v0.1 vertical slice ships with 2 characters: smuggler and + detective. Inverted perspectives on the same world. +- **D-023:** Three-tier content model. Tier 1 (authored drama) drawn from + a pool at game start. Tier 2 (templated). Tier 3 (procedural filler). +- **D-029:** Population entanglement ratio 30/50/20, varies per seed. +- **D-010:** Deterministic simulation. Same seed + same content = identical + world state. +- **D-039:** Wow moment 4 is the Divergence Reveal — same room, different + character, different everything. This is THE replayability payoff. +- **D-041:** Knowledge graph is per-entity. Characters start with different + knowledge. +- **D-032:** Separate monologue pools per character. +- **Seed config schema** (ticket #394): Records seed value, character + selection, pool draws, template assignments, starting knowledge. + +### What's open + +- **Q-011:** Character selection and playable characters — not yet discussed. + Which characters? How different are starting positions? Canon or original? +- **Q-010:** Storyteller AI design — pacing rules, structural vs dramatic + randomness. Adjacent to this workshop but NOT in scope to fully resolve. + +### What's NOT in scope + +- Full character roster beyond v0.1 (two characters first, prove it works) +- Endgame quest design (no endgame exists yet — note toggles, defer design) +- Storyteller pacing algorithm (Q-010 is a separate workshop) +- Character naming edge cases (namespace collisions with NPCs — note, defer) +- Full quest system architecture (scope to: what quest SHAPES are seeded at + creation, not the full quest pipeline) + +### Design guardrail + +Character selection determines your starting **information position** and +**social graph**, not your capabilities. Characters have different verbs +available, not different success probabilities for the same verbs. The +smuggler doesn't get "+5% to cargo inspection" — the smuggler gets "Slip +Manifest" as a verb the detective never sees. This is D-005's intent and +D-028's access tier system in practice. Do not drift toward stat sheets. + +## Key Questions + +### Q1: What does character selection actually select? + +Three possible models — the workshop must pick one (or a hybrid): + +**A. Fixed archetype roster.** Player picks "smuggler" or "detective" from a +list. Each is a fully pre-authored starting position. Knowledge from +playthrough 1 fully transfers — you know exactly where the smuggler starts. + +**B. Archetype + generated instance.** Player picks "smuggler" but YOUR +smuggler is procedurally placed in the social web. Different starting +coworkers, different shift schedule, different corridor assignment. Knowledge +partially transfers — you know smuggler life, but not this smuggler's life. + +**B2. Archetype + curated instance.** Player picks "smuggler," sees 2-3 +procedurally generated social configurations, and picks one. RimWorld's +colonist reroll mechanic — curation from a generated pool rather than full +specification. Middle path between A and B. + +**C. Fully custom.** Player picks background axes (profession, social +tier, faction affinity). No pre-authored archetype. Maximum variation, +maximum authoring cost. + +For each model: what are the replayability implications? What's the +authoring cost? What does this mean for the Divergence Reveal (D-039)? + +Also consider: does the player configure starting knowledge weights, or +is starting knowledge fully determined by archetype? (DF's embark skill +point system lets players shape starting capability within constraints.) + +### Q2: The seed boundary — what varies by what? + +Produce a canonical three-column table: + +| Determined by world seed | Determined by character choice | Player-configured | +|--------------------------|-------------------------------|-------------------| +| ? | ? | ? | + +Examples to place: NPC relationships, which NPCs are compromised, +starting location, starting knowledge graph state, access tiers, +gate activation timing, conspiracy shape, population entanglement +ratio, THE FRIEND identity, starting inventory... + +Include a fourth implicit column: **what information exists in the world +but is inaccessible to this character?** Not locked behind a mechanic — +just absent from their information space entirely. The gap between what +the world contains and what the character can see IS the replayability. +(See: Obra Dinn in Reference Games.) + +This table IS the workshop's primary deliverable. Get it right and every +downstream system knows its inputs. + +### Q3: How does gate activation relate to character choice? + +The contamination/conspiracy discovery trigger — the moment the game shifts +from daily life to investigation. Two sub-questions: + +**A.** Does character choice change WHEN you discover contamination, or only +HOW you experience it? (Detective flags cargo anomalies early via lattice +analysis. Smuggler witnesses something directly. Same world-state, different +discovery paths.) + +Consider Pentiment's model: the murder happens near you, not TO you. You're +pulled in by proximity and relationship, not by being the assigned +investigator. Is gate activation something that happens to the world (and +the character stumbles into it), or something the character triggers through +their specific access? + +**B.** Are discovery paths authored per character (detective always discovers +via X) or emergent (character knowledge graph + storyteller pacing = different +discovery window per playthrough)? What's the minimum authored content needed +per character to make gate activation feel character-specific? + +Explicit RimWorld check: is contamination timing a player-configurable +"storyteller" choice, fixed per character, or emergent from play? Don't +collapse pacing control into archetype selection. + +### Q4: Quest seeding — templates vs randomization + +The user's directive: "the quest system should offer relevant randomized +quests based on creation instead of going fully scripted." Scope this to: + +- What quest SHAPES (not specific quests) are determined at character + creation? (e.g., smuggler gets logistics-flavored side quests, detective + gets investigation-flavored ones) +- How do Tier 2 templates (D-023) interact with character choice? Does the + smuggler's template pool differ from the detective's? +- What is authored (main quest templates, scripted quality) vs generated + (side content, character-relevant variations)? +- How many quest templates are needed per character for v0.1 to feel varied? +- Does the character have visible long-term goals the game tracks? Does the + smuggler TELL you what they want (goals screen) or do wants emerge from + play? (The Sims' wants/aspirations system as reference.) + +### Q5: Game conditions and toggles + +The user wants players to be able to configure their experience. But some +toggles destroy the game's core tension. Define: + +- **What CAN be toggled:** Challenge intensity, optional content modules, + timer pressure, specific life-sim subsystems +- **What CANNOT be toggled:** Core conspiracy simulation, investigator + faction presence, information asymmetry. These exist whether the player + sees them or not. +- **Starting location selection:** Is this a player choice or determined + by archetype? If player choice, what does it mean for authored content? +- **Enable/disable endgame quests:** Note for future design. What's the + minimum we need to decide NOW vs what can wait until endgame exists? + +### Q6: The playthrough 2 test + +Concrete synthesis test. Nigel presents the following scenario to the room: + +> You've played the smuggler on seed X. You now know: Kael is trying to +> exit the ring. Sera Venn is protecting Naia. The detective flagged your +> manifest on Day 2. The contamination hit during the evening shift at +> The Last Shift. You pick the detective on the SAME seed. Minute 1: you +> arrive at the Commission office. Minute 5: your first assignment. +> Minute 10: you walk into The Terminal where you spent 30 hours as the +> smuggler. + +Against the combined design from Rounds 1-4, each participant answers: + +1. What does the detective see in The Terminal that the smuggler never saw? +2. What does the detective's monologue say about Kael — whom the smuggler + considered a friend? +3. Does the contamination trigger differently, or at the same moment via + a different path? +4. Name one thing the player LEARNED in playthrough 1 that changes how + they PLAY playthrough 2 — not metagaming, but genuine new understanding. + +If participants can't answer these concretely, the design has a gap. +Find it and fix it before the workshop closes. + +## Reference Games + +### Dwarf Fortress — the fossil record + +World generation creates centuries of invisible history. The player never +reads a history log — they excavate its consequences. A collapsed +civilization left ruins. A grudge between two species shapes who attacks +your fort. The history is SUBSTRATE, not content. + +The lesson for us: the world seed should produce CONSTRAINTS and RESIDUES +that make the current situation feel inevitable. How long has this smuggling +ring been operating? What's its history of near-discovery? Which institutional +figures already have kompromat on them? The player never sees this directly +but feels its weight on every NPC relationship state they encounter. + +Also relevant: the embark skill point system — players shape starting +capability within constraints rather than receiving a fixed loadout. Consider +for Q1: does the player configure starting knowledge within their archetype? + +### RimWorld — the separation principle + +Storyteller selection (Cassandra/Phoebe/Randy) controls pacing, not content. +Scenario defines starting resources and constraints. Colonist generation is +partially random, partially player-curated (reroll, choose skills). + +**Key lesson 1:** The storyteller and starting conditions are SEPARATE +choices. Our gate activation / contamination pacing (Q3) maps to storyteller +selection. Our character archetype maps to scenario. Don't collapse them. + +**Key lesson 2:** Player CURATION from a procedurally generated set is +different from player SPECIFICATION of a custom set. RimWorld's colonist +reroll is a point-buy system hidden behind a reroll interface. For us: +model B2 — see 2-3 generated social configurations for "your smuggler" and +pick one. This is a viable middle path that deserves to be on the table. + +### The Sims — verbs, not stats + +Traits in The Sims are PERMISSION SYSTEMS for social interactions, not stat +modifiers. The Outgoing Sim doesn't get +20% to social checks — they get +access to DIFFERENT VERBS. They can autonomously initiate conversations the +Introvert Sim cannot. This is exactly our access tier system (D-028 Layer 1). + +**Key lesson 1:** Character creation changes which verbs you have, not how +well you perform shared verbs. The smuggler gets "Slip Manifest." The +detective gets "Pull Records." Neither is better — they're different +information-gathering tools for the same world. + +**Key lesson 2:** Neighborhood placement as replayability driver. The lot +you choose positions you relative to neighbor NPCs, which determines which +relationships bootstrap organically through proximity. Early relationships +form through proximity, not player initiative. The smuggler starts embedded +in The Terminal — relationships with ring members bootstrap before the +player does anything. The detective starts at the Commission — different +organic relationships form. THIS is the structural driver that makes the +same seed play differently. + +### Disco Elysium — observation filters, not capabilities + +D-005 already cites Disco Elysium as a design reference. The Thought +Cabinet is a permission system for new dialogue options and monologue lines. +Building a character in DE doesn't give you stats — it gives you access to +different OBSERVATIONS of the same world. Intellect doesn't make you +smarter — it makes your character say different things to themselves when +they see the same evidence. + +**Key lesson:** Character build determines what your character NOTICES, not +what they can DO. This is D-032 (separate monologue pools) and D-041 +(per-entity knowledge graph) in one reference. If participants are thinking +about Q1 models without DE on the table, they'll drift toward stat-system +thinking. DE keeps them on the observation-filter track. + +### Return of the Obra Dinn — information gap as presence + +Obra Dinn demonstrates that information asymmetry can be the ENTIRE game. +You piece together events from fragments. The information gap feels like +presence, not absence — you FEEL the weight of what you can't see yet. + +**Key lesson for Q2:** The seed boundary table needs to account for what +exists in the world but is invisible to this character. Not locked behind a +mechanic — just absent from their information space. Character A's world +contains things that are simply not in Character B's world. That gap is +the pull that drives playthrough 2. + +### Pentiment — gate activation by proximity + +Pentiment commits to a single-character perspective. The gate activation +question it answers: "what triggers the player from daily life into +investigation?" A murder happens near you, not TO you. You're pulled in +by proximity and relationship, not by institutional assignment. The player +character is NOT the assigned detective — they're a witness with skills. + +**Key lesson for Q3:** Same discovery timing, but character-dependent tools +for responding to it. The contamination doesn't care who you are — it +happens. But your character's position determines whether you see it as +threat, opportunity, or puzzle. + +### The common thread + +In all six games, the setup screen generates ASYMMETRIC STARTING CONDITIONS. +The same world, entered from different positions, produces different +information access, different social proximity, and different verb +availability. The asymmetry IS the replayability. Our character creation +should produce a starting POSITION in an information landscape — not a +stat block, not a story, not a difficulty setting. + +## Participants and Roles + +| Agent | Role | Why they're here | +|-------|------|-----------------| +| Nigel | Replayability lead | Structural randomness, seed design, "what happens on playthrough 10?" | +| Paula | Narrative lead | Starting NPC relationships, character-specific story hooks, social web implications | +| Gestalt | Systems lead | How character choice propagates through mechanics (KG, dialogue tiers, movement, perception) | +| Miri | Worldbuilding | Which archetypes fit the Krenn System, canon constraints, lore accuracy | +| Tyre | Architecture | Implementation cost reality check. "That's 4 new ECS components — is it worth it?" | +| Qatux | Documenter | Track decisions, cross-references, dissent. Maintain running reference list. | + +## Round Structure + +### Round 1 — The Lens Question (Nigel leads) + +Single question: what does character selection actually select? (Q1) + +Nigel opens with a replayability scoring of each model (A/B/B2/C) — 3 +bullet points per model on the replayability axis. This is a BASELINE, not +a verdict. Participants then argue from their domain against that baseline. +This converges faster than open advocacy. + +Target: agree on the model by end of round. + +### Round 2 — The Seed Boundary (Gestalt leads) + +Given the model from Round 1: draw the exact line between world seed, +character choice, and player customization. Produce the three-column +table (Q2). Each participant fills in their domain's rows. + +### Round 3a — Gate Activation (Paula leads) + +How does contamination trigger work per character? (Q3) +This flows directly from Round 1's model decision. Paula leads because +gate activation is fundamentally a narrative question — when does the +story shift? + +### Round 3b — Quest Seeding (Nigel leads, Tyre has implementation floor) + +How do quest templates interact with character choice? (Q4) +Nigel leads because quest variation is the replayability engine. Tyre gets +explicit authority to reject quest template proposals that require new +architecture — every template decision has an implementation cost that +spirals without active checking. + +### Round 4 — Conditions, Toggles & Synthesis (all) + +Game conditions and what's toggleable (Q5). Then run the concrete +playthrough 2 test (Q6). Nigel presents the scenario. Each participant +must answer the four concrete questions. If they can't, the design has +a gap — find it and fix it before closing. + +## Required Reading for Participants + +- `decisions/scope.md` — D-005, D-013, D-027, D-029, D-053 +- `decisions/content.md` — D-023, D-028, D-032, D-034 +- `decisions/architecture.md` — D-041 (knowledge graph) +- `decisions/questions.md` — Q-010, Q-011 + +## Expected Outputs + +- **D-record:** Character creation model (resolves Q-011) +- **D-record:** Seed boundary table (what varies by seed vs character vs player) +- **D-record:** Gate activation trigger design +- **D-record or Q:** Quest seeding model (may produce a Q if full design deferred) +- **D-record:** Game condition toggles (what's configurable, what isn't) +- Ticket updates for Sprint 19+ backlog as needed diff --git a/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md b/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md new file mode 100644 index 000000000..b4bdedded --- /dev/null +++ b/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md @@ -0,0 +1,82 @@ +# Workshop Outcomes: v0.1 Content Gap Analysis + +**Workshop:** v0.1 Content Gap Analysis +**Date:** 2026-02-11 +**Rounds:** 2 (Analysis + Synthesis) +**Participants:** Mellanie, Paula, Araminta, Miri, Gestalt, Ozzie +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — all decisions actioned, tickets created +**Full notes:** `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md` + +--- + +## What the Workshop Accomplished + +Six agents independently analyzed 9 content layers across the vertical slice (D-027), then synthesized across all outputs. The project lead issued 9 directive decisions between rounds. Remarkable cross-agent convergence: the Dual Lens Guide, monologue as primary carrier, and the tag taxonomy were independently identified by multiple agents without coordination. THE FRIEND concept evolved from Ozzie's emotional instinct to Paula's structural design to Mellanie's authoring plan in a single workshop. + +--- + +## Decisions Produced + +| ID | Decision | Domain | Source | +|----|----------|--------|--------| +| D-032 | Separate monologue pools per character | content.md | Lead directive #1 | +| D-033 | Entity color = relationship to player | perception.md | Araminta R1 + lead directive #2 | +| D-034 | THE FRIEND production-level NPC pattern | content.md | Ozzie concept + lead directive #4 + Paula design | +| D-035 | Converged tag taxonomy for line pools (6+3 tags) | content.md | Gestalt + Mellanie convergence | +| D-036 | Sova Transit District / Krenn System as v0.1 setting | content.md | Miri R1 + lead directive #6 | +| D-037 | Contraband specification | content.md | Miri R1/R2 | +| D-038 | Audio in v0.1 scope via Stable Audio Open (8 files) | scope.md | Lead directives #3 + #9 | +| D-039 | v0.1 wow moment scope — all 6 moments | scope.md | Ozzie R1/R2 + lead directive #8 | +| D-040 | Wiki taxonomy structure | process.md | Miri R2 + lead directive #5 | + +All 9 decisions confirmed by project lead between rounds as non-negotiable directives. + +--- + +## Open Questions Identified + +| ID | Question | Owner | Status | +|----|----------|-------|--------| +| Q-012 | How does the generation expansion pass work? LLM, template-based, or rule-based? | Gestalt, Mellanie | Raised this workshop | +| Q-013 | How does the line previewer handle THE FRIEND's temporal progression? | Gestalt, Dudley | Raised this workshop | +| Q-014 | Audio timing with monologue — when does the chime fire relative to text? | Gestalt, Ozzie | Raised this workshop | +| Q-015 | Does 4x generation expansion apply to THE FRIEND's custom lines? | Mellanie, Gestalt | Raised this workshop | +| Q-016 | Knowledge hierarchy for monologue prerequisites | Gestalt, Paula | Raised this workshop | +| Q-017 | Triangle pressure threshold — what events trigger escalation? | Gestalt, Paula | Raised this workshop | + +--- + +## Tickets Created + +48 total tickets (42 new + 6 updates). See `docs/workshops/content-gap-analysis_v0_1/TICKETS.md` for full list. + +**Critical (9 new):** #297 Kael Davan full profile, #298 Sera Venn full profile, #299 Opening hook (smuggler), #300 Opening hook (detective), #301 Wiki taxonomy, #302 Sova Texture Appendix, #303 v0.1 Visual Grammar, #304 Entity Color System Spec, #305 Dialogue selection pipeline. + +**High (23 new):** Content and narrative (#306, #307, #310, #328), visual and spatial layouts (#311-318), worldbuilding (#319-322), systems and implementation (#308, #309, #323-327). + +**Medium (10 new):** Mirror moments, tutorial content, environmental standards, tell derivation (#329-338). + +**Updated (6):** #261 promoted to critical with expanded scope; #189, #168, #124, #90, #193 updated. + +--- + +## Key Flags + +- Detective's FRIEND confirmed as Sera Venn (Paula, not Mellanie's Lera proposal). Lera Sessik remains the bar owner. +- NPC triangle count: Paula reconciled from 7 to 5 triangles in Round 2. Five-triangle model is canonical. +- Tag taxonomy convergence was independent: Mellanie and Gestalt proposed nearly identical structures without coordination. +- Dual Lens Guide (#261) is the single highest-risk dependency — everything downstream blocks on it. + +--- + +## Critical Path Produced + +Dual Lens Guide (#261) → Voice Kits → THE FRIEND Content Packs → Validation → Content at Scale. + +Three parallel tracks: narrative (Paula), setting (Miri), systems (Gestalt + Araminta) — converging at content pack production. + +--- + +*Compiled by Qatux. Source: `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md`, `TICKETS.md`. Decisions in `decisions/content.md` (D-032 through D-040 excl. D-033 in perception.md, D-038/D-039 in scope.md, D-040 in process.md).* diff --git a/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md b/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md new file mode 100644 index 000000000..f59af5bac --- /dev/null +++ b/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md @@ -0,0 +1,90 @@ +# Workshop Outcomes: Knowledge Graph & Information Boundaries + +**Workshop:** Knowledge Graph & Information Boundaries +**Date:** 2026-02-11 +**Rounds:** 2 (Design + Synthesis) +**Participants:** Tyre, Gestalt, Paula, Dudley, Si +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — fully actioned, decisions filed, tickets created +**Full notes:** `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md` + +--- + +## What the Workshop Accomplished + +Replaced the `InformationInventory { known_facts: Vec }` placeholder with a fully specified knowledge graph data model. Five agents analyzing from different angles converged independently on all fundamentals: per-entity ECS component, stable entity IDs, per-entry provenance tracking. The synthesis resolved the only substantive debate (centralized resource vs. per-entity component) unanimously in favour of per-entity. The resulting D-041 spec is the foundation for asymmetric information as a playable mechanic — and became load-bearing for D-010, D-011, D-017, D-028, D-033, and Q-016. + +--- + +## Major Decision Produced + +### D-041: Knowledge Graph Data Model + +The core design decision of this workshop. Full specification in `decisions/perception.md`. + +Key architectural choices: +- `KnowledgeGraph` as a Bevy ECS `Component` on each entity (not a centralized resource) +- `StableEntityId` (`u64`-based) for cross-reference stability; runtime `EntityRegistry` for bidirectional mapping +- `BTreeMap` + `BTreeMap` per entity +- Four confidence levels: `Direct` > `KnowsDetails` > `KnowsOf` > `Suspects` +- Three knowledge states: `Active`, `Stale`, `Contradicted` +- `KnowledgeSource` tracked per-entry (not per-graph): `DirectObservation`, `ToldBy`, `Background`, `Heard` +- Event-driven updates via `KnowledgeEventQueue`; decay pass once per game-minute +- Sprint 2 scope: data structures + direct observation + basic decay only + +### Additional Decisions Implied (formalized later) + +The workshop produced the architectural foundation that fed into: +- D-012: Information boundaries (per-character knowledge isolation) +- Formal resolution of Q-016: Knowledge hierarchy (`Suspects` < `KnowsOf` < `KnowsDetails` < `Direct`) + +--- + +## Open Questions Identified + +| ID | Question | Sprint Impact | Resolved By | +|----|----------|---------------|-------------| +| Q-024 | Gossip propagation timing (immediate vs queued) | Sprint 3+ | D-080 (knowledge-flow-npc-boundaries workshop) | +| Q-025 | Knowledge graph cap and eviction policy | Sprint 3+ | D-080 (closed: no cap needed at projected v0.1 scale) | +| Q-026 | Contradiction detection algorithm | Sprint 3+ (THE FRIEND arc) | D-083 (knowledge-flow-npc-boundaries workshop) | + +None blocked Sprint 2. + +--- + +## Tickets Created + +8 new tickets added to Sprint 2, all under epic #351. Sprint 2 expanded from 14 to 22 tickets (+6.5 developer-days). + +| # | Title | Priority | Estimate | +|---|-------|----------|----------| +| #361 | KnowledgeGraph component + types (D-041) | critical | 1 day | +| #362 | StableEntityId + EntityRegistry resource | critical | 1 day | +| #363 | KnowledgeEventQueue + processing system | high | 0.5 day | +| #364 | Direct observation knowledge flow | critical | 0.5 day | +| #365 | Basic knowledge decay system | high | 0.5 day | +| #366 | Observer snapshot knowledge integration | critical | 1 day | +| #367 | Knowledge graph unit test suite | high | 1 day | +| #368 | Knowledge vocabulary for v0.1 content | high | 0.5 day | + +**Existing tickets affected:** +- #89 (Information inventory) — cancelled, subsumed by #361 +- #269 (CauseChain component) — marked done (already implemented) +- #138-142 (Information boundary epics) — reparented under #351; #139, #141, #142 deferred to Sprint 3; #140 cancelled (merged into #366) + +**Critical path impact:** #361, #362, #363, #364 added serially to Sprint 2 critical path (10 tickets serial, up from 6). + +--- + +## Sprint 2 Completion Criteria (Added by Workshop) + +Two new acceptance criteria added to Sprint 2's definition of done: +- Entity color reflects relationship state from knowledge graph (#361, #366) +- Remembered (not visible) entities appear as ghosts at last-known position (#361, #366) + +**Knowledge graph proof:** Observe an NPC, walk away, return. NPC appears as ghost at last-known position while not in LOS. Color shifts by relationship state. + +--- + +*Compiled by Qatux. Source: `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`. Primary decision in `decisions/perception.md` (D-041).* diff --git a/docs/workshops/v01-content-scoping/workshop-outcomes.md b/docs/workshops/v01-content-scoping/workshop-outcomes.md new file mode 100644 index 000000000..196460f28 --- /dev/null +++ b/docs/workshops/v01-content-scoping/workshop-outcomes.md @@ -0,0 +1,116 @@ +# Workshop Outcomes: v0.1 Content Scoping + +**Workshop:** v0.1 Content Scoping +**Date:** 2026-02-12 +**Rounds:** 2 + closing round (lead resolutions) +**Participants:** Gestalt, Paula, Tyre, Mellanie, Stig, Dudley, Si, Qatux +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** CLOSED — all recoverable decisions filed, tickets created +**Full notes:** `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Applied the Wiki Review's long-term generator strategy to the immediate v0.1 hand-authored proof. Produced 20 decisions (D-042 through D-061), 38 new tickets, canonical NPC mapping for 17 characters, the 16-key EntityKnowledge specification, full content directory architecture, and a Sprint 3-5 roadmap. Tyre and Dudley independently produced structurally identical ObserverSnapshot v3 definitions without coordination — confirmed the architecture was sound. Lead issued 4 decisions resolving the major Round 1 disagreements between rounds, then resolved 3 remaining questions in a closing round. + +**Note on decision IDs:** Several IDs assigned at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-087 (content directory structure), D-089 (NPC canonical mapping method), D-091 (EntityKnowledge 16-key canonical set), Q-031 through Q-034. + +--- + +## Decisions Produced + +### From Round 1 Consensus (7) + +| ID | Decision | Domain | +|----|----------|--------| +| D-042 | Drin promoted from Tier 3 to Tier 2 | content.md | +| D-043 | THE NOBODY mechanic deferred to v0.2; hidden data ships in v0.1 content | scope.md | +| D-044 | v0.1 interaction model: 7 verbs (Move, Look, Monologue, Examine Object, Examine NPC, Talk, Overhear) | scope.md | +| D-045 | v0.1 scope IN: news ticker, PC-as-NPC, time progression, relationship state transitions | scope.md | +| D-046 | v0.1 scope OUT: inventory, stealth, combat, save/load, lattice modification | scope.md | +| D-047 | v0.1 triangles: 3 active forks (T1, T2, T4), 2 passive tensions (T3, T5) | content.md | +| D-048 | Client receives all text from server via state updates; client does not load content files | architecture.md | + +### From Round 2 + Closing (13) + +| ID | Decision | Domain | +|----|----------|--------| +| D-049 | YAML is the content file format for v0.1; RON is optional build-time optimization | architecture.md | +| D-050 | Gestalt's NPC pattern/motivation mapping canonical for v0.1; Paula's emotional layer becomes v0.2 annotations | content.md | +| D-051 | v0.1 ships single context-sensitive verb; multi-verb architecture modeled underneath | architecture.md | +| D-052 | 3-state pause: Normal (100%), Overlay (50%), Paused (0%); server-authoritative | architecture.md | +| D-053 | Self-contained triangle forks for v0.1; no cross-triangle cascade (v0.2) | content.md | +| D-054 | ObserverSnapshot v3: adds sim_speed, nearby_interactions, active_dialogue, monologue, overheard, knowledge_updates, examine_result, ticker_headlines | architecture.md | +| D-055 | 16 EntityKnowledge keys; 4 new role-perspective keys; trust_read merged into trust_level; secret_held → leverage_held | architecture.md | +| D-056 | PC voice registers: smuggler (feeling-first, fragments, physical); detective (analysis-first, complete sentences, institutional) | content.md | +| D-057 | Content directory: content/ with _schema/, global/, districts/ top-level split; JSON Schema validation at build time | process.md | +| D-058 | THE FRIEND content pack template: Kael Davan, 91 lines across 5 arc phases | content.md | +| D-059 | Monologue display: 160 char max, 2-line max, 4-6s display, 2s cooldown, queue depth 1, 9-level priority | architecture.md | +| D-060 | actions[] renamed to verbs[] across all surfaces | architecture.md | +| D-061 | No ticket merges across domain teams | process.md | + +### Retrospective Filings (ID collisions resolved) + +| ID | Decision | Domain | +|----|----------|--------| +| D-087 | Content directory structure (content/ split) | process.md | +| D-089 | Canonical NPC pattern/motivation mapping method | content.md | +| D-091 | EntityKnowledge 16-key canonical specification | architecture.md | + +--- + +## Open Questions Carried Forward + +| ID | Question | Status | +|----|----------|--------| +| Q-031 | NPC surnames for Drin, Sess, Tav awaiting Miri validation | Informational | +| Q-032 | Interaction struct naming: AvailableActions (Tyre) vs EntityInteractions (Dudley) | Resolved at implementation | +| Q-033 | 695 authored items: validated as scope input but not independently verified | Informational | +| Q-034 | Dialogue max-width: pixel value for 20% height / max-width constraint | Pending lead call | + +None blocked Sprint 3. + +--- + +## Tickets Created + +38 new tickets + 10 existing ticket updates. See `si-ticket-changes.md` for full list. + +**Teams:** copy (21), server (13), client (2), ci (1). + +**Critical path:** #261 (Dual Lens Authoring Guide) is the single biggest blocker — directly blocks 9 downstream tickets across the content pipeline. Five-day time-box recommended. + +| Series | Count | Domain | +|--------|-------|--------| +| A (Wiki content fixes) | 8 | copy | +| B (Style guides + specs) | 5 | copy | +| C (Content directory + schemas) | 10 | copy/server/ci | +| D (Design specs) | 2 | server/copy | +| NEW 1-7 (Workshop rounds) | 7 | copy | +| NEW 8-14 (Lead decisions, excl. killed NEW-12) | 6 | server/client | + +**Killed:** NEW-12 (client pause state machine — pause is server-authoritative, client sends IPC command only). + +**Sprint allocation:** Sprint 3 — foundations and specs. Sprint 4 — content conversion and authoring begins. Sprint 5+ — content at scale. + +--- + +## NPC Canonical Mapping (17 NPCs) + +The Gestalt-Paula synthesis produced the v0.1 canonical pattern/motivation mapping for all 17 Sova NPCs. This is the authoritative reference for content authoring. + +| Name | Tier | Pattern | Motivation | +|------|------|---------|-----------| +| Kael Davan | T1 | FRIEND | OPERATOR | +| Sera Venn | T1 | FRIEND | WITNESS | +| Naia Tamm | T1* | MIRROR | CIVILIAN | +| Voss, Lera, Torek, Devra, Maret, Resha, Drin, Renn, Pell, Harek | T2 | (varied) | (varied) | +| Sess, Olin, Sabel, Tav | T3 | (varied) | (varied) | + +Off-stage: Nils Davan — GHOST + HANDLER. + +--- + +*Compiled by Qatux. Source: `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`. Decisions in relevant `decisions/` domain files (D-042 through D-061, D-087, D-089, D-091). Open questions in `decisions/questions.md` (Q-031 through Q-034).* diff --git a/docs/workshops/v01-gap-analysis/workshop-outcomes.md b/docs/workshops/v01-gap-analysis/workshop-outcomes.md new file mode 100644 index 000000000..2f4bda737 --- /dev/null +++ b/docs/workshops/v01-gap-analysis/workshop-outcomes.md @@ -0,0 +1,97 @@ +# Workshop Outcomes: v0.1 Gap Analysis + +**Workshop:** v0.1 Gap Analysis +**Date:** 2026-02-11 +**Rounds:** 2 (Gap identification + Synthesis) +**Participants:** Gestalt, Tyre, Ozzie, Paula, Nigel, Gore, Hoshe +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — fully actioned, decisions confirmed, tickets created +**Full notes:** `docs/workshops/v01-gap-analysis/si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Stress-tested the full v0.1 plan (232 tickets, 7 initiatives) against three tracks: strength of concept proof, fun and wow factor, and completeness. Found significant gaps: missing infrastructure (collision, pathfinding, time system), a missing experience layer (observation pipeline, interaction verbs), and systemically underpriced tickets across social, dialogue, and replayability systems. The workshop ended with 273 tickets (+41) and a clear Sprint 1-5 dependency chain. + +Key outcome: the workshop revealed that tile collision, A* pathfinding, the observation event generator, and the interaction dispatcher were absent from the ticket catalog despite being architectural blockers for almost everything else. These were added at critical priority. + +--- + +## Decisions Confirmed + +The workshop confirmed 6 decisions. These fed directly into later formal D-records: + +| Decision | Formal Record | Notes | +|----------|---------------|-------| +| Deterministic replay is an architectural requirement, not just test infrastructure | D-010 (reinforced) | Promoted #201 to critical | +| Time system: 10 tps, 4 day phases, diegetic clock | D-031 | Resolved Q-009; ticket #25 renamed and promoted | +| Godot test framework = gdUnit4 | process.md | Tyre reversed GUT recommendation after Hoshe's analysis | +| IPC testing = three-layer architecture (fixture, protocol mock, real integration) | architecture.md | Hoshe + Tyre | +| CauseChain is a production component, not test pollution | architecture.md | #269 created | +| Dual Lens Authoring Guide must precede content authoring | D-038 extended | All agents converged | + +These decisions fed into the scope and architecture domain files. The three-tier content system (D-023), vertical slice (D-027), and population ratios (D-029) were validated against the gap analysis findings and confirmed as sound. + +--- + +## Ticket Changes + +**Before:** 232 tickets | **After:** 273 tickets (+41) + +| Change Type | Count | +|-------------|-------| +| Priority promotions (existing) | 15 | +| New epics | 3 | +| New stories | 38 | +| Dependency records added | 21 | +| Renamed + promoted | 1 (#25) | + +### Priority Promotions (15 tickets) + +**Medium → High (11):** #103 (relationship dynamics), #105 (tolerance threshold triggers), #171 (trust-gated gossip), #172 (unprompted disclosure), #173 (trait modifier system), #175 (entanglement ratio), #176 (NPC pool generation), #121 (character voice variation), #126 (medium-range visual indicators), #162 (storyteller module activation), #178 (seed-based variation). + +**High → Critical (3):** #201 (deterministic replay), #182 (divergent starting knowledge), #183 (divergent relationships). + +### New Epics (3) + +| ID | Title | Priority | +|----|-------|----------| +| #233 | Movement & Collision | critical | +| #234 | Observation & Interaction | critical | +| #235 | Game State Management | high | + +### Critical New Stories + +| ID | Title | Priority | +|----|-------|----------| +| #236 | Tile collision system | critical | +| #239 | Observation event generator | critical | +| #240 | Player interaction system and dispatcher | critical | +| #237 | Tile-based A* pathfinding | high | +| #238 | NPC path following and movement | high | +| #253 | Monologue content architecture | high | +| #261 | Dual Lens Authoring Guide | high | +| (+ 31 more stories) | | high/medium/low | + +### Critical Path Produced + +``` +Sprint 1: #236 (collision) → #237 (pathfinding) → #238 (NPC movement) + #25 (game clock) → #88 (daily routines) +Sprint 2: #239 (observation generator) + #240 (interaction dispatcher) +Sprint 3: Observation verbs, NPC conversation, monologue, triangle escalation +Sprint 4: Content pipeline (#261 dual lens guide blocks all content packs) +Sprint 5: Validation and playtest protocol +``` + +--- + +## Open Questions + +None formally raised as Q-records by this workshop — the gap analysis was primarily a ticket and priority exercise. Questions about content authoring (Q-012 through Q-017) were raised in the companion content-gap-analysis workshop the same day. + +--- + +*Compiled by Qatux. Source: `docs/workshops/v01-gap-analysis/si-ticket-changes.md`. Decisions confirmed fed into `decisions/scope.md` (D-023, D-027, D-029) and `decisions/architecture.md` (D-010, D-031).* diff --git a/docs/workshops/wiki-review/workshop-outcomes.md b/docs/workshops/wiki-review/workshop-outcomes.md new file mode 100644 index 000000000..702389a80 --- /dev/null +++ b/docs/workshops/wiki-review/workshop-outcomes.md @@ -0,0 +1,116 @@ +# Workshop Outcomes: Wiki Review & Content Standards + +**Workshop:** Wiki Review & Content Standards +**Date:** 2026-02-12 +**Rounds:** 4 + lead interview between R3 and R4 +**Participants:** Paula, Mellanie, Miri, Gestalt, Gore, Nigel, Ozzie, Tyre, Qatux, Si +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** CLOSED — all recoverable decisions filed, tickets created +**Full notes:** `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Began as a v0.1 wiki review (45 files, ticket #368) and was redirected between rounds 2 and 3 by a major strategic reframe: the lead declared the target as 300 populated worlds before DLC. This transformed the workshop from a content authoring strategy into a generator specification strategy. All Round 4 responses independently arrived at the same conclusion: the workshop had been designing generator specifications all along. The district skeleton, NPC composition matrix, Sacred/Profane framework, and pool architecture were already generator-shaped. + +**The strategic reframe:** Old model: writers produce districts, tooling accelerates writers. New model: engineers produce generators, writers produce generator inputs, tooling IS the product. + +**Note on decision IDs:** Several IDs proposed at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-088 (300-world generator model), D-090 (three-tier world authoring), D-092 (Sacred/Profane/Middle Kingdom framework), Q-030, Q-035 through Q-039. + +--- + +## Decisions Produced + +### Long-Term Strategy Decisions (Confirmed by Lead) + +| ID | Decision | Domain | +|----|----------|--------| +| D-088 | 300 worlds before DLC — generator model required | scope.md | +| D-090 | Three-tier world authoring: Landmark (10-15 hand-authored), Regional (30-50 template), Generated (230-260 procedural) | scope.md | +| D-092 | Sacred/Profane/Middle Kingdom randomization framework | architecture.md | + +### Supporting Decisions (Rounds 1-2, v0.1 Specific) + +| ID | Decision | Domain | +|----|----------|--------| +| Q-030 | Cultural ingredients menu: 6 ingredient categories, Heritage OPTIONAL, derivation function produces cultural parameters | pending formalization | +| — | Hael renamed to Naia Tamm (resolves Kael/Hael sonic collision) | content.md | +| — | Three-system NPC architecture: Thematic Patterns + Functional Motivations + Composition rules | content.md | +| — | Cultural brief = generation seed (seed.yaml + brief.md dual artifact) | process.md | +| — | 8 PC archetypes at v1.0; archetypes are FLUID positions, not classes | scope.md | +| — | THE NOBODY: dynamic tier promotion (NOBODY → NOTICED → RECOGNIZED → KNOWN → INVESTED) | scope.md | + +The specific v0.1 decisions (Naia rename, NPC architecture, THE NOBODY, THE MIRROR) were carried forward and filed in the subsequent v0.1 Content Scoping workshop where IDs were formally assigned. + +--- + +## Open Questions Raised + +| ID | Question | Status | +|----|----------|--------| +| Q-030 | Cultural ingredients menu: full formalization of 6 categories, null-heritage behavior, derivation function specification | Pending | +| Q-035 | Naming algorithm: phonetic rules vs word lists — which approach for 300-world cultural naming | Pending | +| Q-036 | Gate topology design: connectivity requirements, hub placement, small-world properties | Pending | +| Q-037 | Storyteller cultural literacy: how storyteller adapts pacing to cultural trust-building rates | Pending | +| Q-038 | NPC pattern composition rules: forbidden/preferred combination formalization | Pending | +| Q-039 | Modding toolkit: content pack manifest, ADD/REPLACE/MERGE overlay operations | Pending | + +--- + +## Tickets Created + +29 new tickets (1 epic, 9 stories, 19 tasks) + 4 existing ticket updates. See `si-ticket-changes.md` for full list. + +**Teams:** copy (21), server (7), ci (1). + +### Existing Ticket Updates (4) + +| ID | Action | +|----|--------| +| #301 | Update description with concrete taxonomy rules from workshop | +| #319 | Update — Miri's Krenn brief supersedes original scope | +| #368 | Mark done — wiki at wiki/ is the delivered output | +| #261 | No change — confirmed still blocking, assign to copy when ready | + +### New Epic + +**Wiki Review Workshop Outputs** — parent epic for all workshop-produced tickets. Priority: high. Team: copy. + +### Selected New Tickets + +| Series | Count | Examples | +|--------|-------|---------| +| A (Wiki content updates) | 12 | A1 canonical names, A2 Hael→Naia rename, A3 Krenn brief, A7 smuggler attributes, A11 Triangle 1 fix | +| B (Style guides + specs) | 5 | B1 NPC Authoring Style Guide, B2 MIRROR spec, B3 PC-as-NPC spec, B4 Smuggler voice card | +| C (Content directory + schemas) | 10 | C1 design doc, C2 directory skeleton, C3 schemas, C9 validate-content CLI | +| D (Design specs) | 4 | D1 cultural_gate design, D2 seed config schema, D3 secondary contraband, D4 news ticker | + +--- + +## Deferred to Future Workshops + +The wiki-review triggered scoping of several follow-on workshops: + +- **Control & Interaction Scheme** — brief written during this workshop; held separately (`docs/workshops/control-interaction/`) +- Perception system gravity variation for world transitions +- Full NPC pattern composition rules (forbidden/preferred lists) +- Gate topology design +- Storyteller cultural literacy +- LLM-assisted content pipeline design +- Modding toolkit specification +- Inter-world political generation + +--- + +## Key Quotes Preserved + +- Nigel: "Two players, same world, same seed, different experience because they noticed different people." +- Gore: "Learning to see is the endgame." +- Gestalt: "Hotline Miami movement + Disco Elysium interaction." +- Gore: "The theme is the field, not any specific phrasing of it. The field is: what does it cost to be human inside something bigger than yourself?" + +--- + +*Compiled by Qatux. Source: `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`. Decisions in `decisions/scope.md` (D-088, D-090) and `decisions/architecture.md` (D-092). Open questions in `decisions/questions.md` (Q-030, Q-035 through Q-039).* diff --git a/server/Cargo.toml b/server/Cargo.toml index d3864c8ce..1afc640c4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -24,3 +24,13 @@ gauntlet = [] [dev-dependencies] serde_json = "1" + +# --------------------------------------------------------------------------- +# Explicit test target for the Layer 3 integration module (D-030, ticket #200). +# tests/integration/mod.rs cannot be auto-discovered by cargo (only top-level +# *.rs files in tests/ are auto-discovered). This declaration makes it a named +# test binary: `cargo test --test integration_layer3`. +# --------------------------------------------------------------------------- +[[test]] +name = "integration_layer3" +path = "tests/integration/mod.rs" diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index af5b395b3..61ab95073 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -80,6 +80,21 @@ impl LocalBridge { } impl SimBridge for LocalBridge { + fn send_handshake(&self) -> Result<(), BridgeError> { + use super::types::{HandshakeMessage, PROTOCOL_VERSION}; + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let payload = rmp_serde::to_vec_named(&msg)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + write_framed(writer.get_mut(), &payload)?; + tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION); + Ok(()) + } + fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { let payload = rmp_serde::to_vec_named(snapshot)?; diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index a09fcaf8f..89ea3c363 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -35,6 +35,11 @@ pub enum BridgeError { /// Abstracts transport layer (D-020) /// Implemented by LocalBridge (stdio) and future NetworkBridge pub trait SimBridge: Send + Sync { + /// Send the protocol handshake as the first framed message (#555). + /// Must be called exactly once, immediately after connection, before + /// any ObserverSnapshot is sent. + fn send_handshake(&self) -> Result<(), BridgeError>; + /// Send an observer snapshot to the client fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>; @@ -55,6 +60,10 @@ impl BridgeResource { } } + pub fn send_handshake(&self) -> Result<(), BridgeError> { + self.inner.send_handshake() + } + pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { self.inner.send_snapshot(snapshot) } @@ -64,15 +73,40 @@ impl BridgeResource { } } +/// Tracks whether the protocol handshake has been sent (#555). +/// Inserted by BridgePlugin as Pending. Set to Complete in main.rs after +/// `send_handshake()` succeeds. `receive_bridge_inputs` logs a warning +/// if inputs arrive while still Pending. +#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandshakeState { + /// Handshake not yet sent. Inputs arriving in this state trigger a warning. + Pending, + /// Handshake sent. Normal operation. + Complete, +} + +impl Default for HandshakeState { + fn default() -> Self { + Self::Pending + } +} + /// Receive inputs from bridge and push to InputQueue pub fn receive_bridge_inputs( bridge: Option>, mut input_queue: ResMut, mut running: ResMut, + handshake: Res, ) { let Some(bridge) = bridge else { return }; match bridge.receive_inputs() { Ok(inputs) => { + if !inputs.is_empty() && *handshake == HandshakeState::Pending { + tracing::warn!( + "Received {} input(s) before handshake completed — processing anyway (forward-compatible)", + inputs.len() + ); + } for input in &inputs { tracing::trace!( "Received input: tick={} action={:?}", @@ -156,6 +190,7 @@ impl Plugin for BridgePlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index d4ddf2a88..0321fc829 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -45,7 +45,7 @@ impl TcpBridge { ); // Set non-blocking so receive_inputs doesn't stall the game loop. - // read_framed handles WouldBlock by returning Ok(None). + // receive_inputs catches WouldBlock from read_framed and returns Ok(vec![]). stream .set_nonblocking(true) .map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?; @@ -129,6 +129,26 @@ impl TcpBridge { } impl SimBridge for TcpBridge { + fn send_handshake(&self) -> Result<(), BridgeError> { + use super::types::{HandshakeMessage, PROTOCOL_VERSION}; + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let payload = rmp_serde::to_vec_named(&msg)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + // Toggle to blocking for reliable handshake delivery. + let stream = writer.get_mut(); + stream.set_nonblocking(false).map_err(BridgeError::Io)?; + let result = write_framed(stream, &payload); + stream.set_nonblocking(true).map_err(BridgeError::Io)?; + result?; + tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION); + Ok(()) + } + fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { let payload = rmp_serde::to_vec_named(snapshot)?; diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index df789e107..777c32e50 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -306,13 +306,13 @@ 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![], examine_result: None, player_knowledge: None, + save_result: None, } } @@ -441,13 +441,13 @@ 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![], examine_result: None, player_knowledge: None, + save_result: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index fdec7a988..6274ea1fa 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,17 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 14; +pub const PROTOCOL_VERSION: u8 = 15; + +/// Handshake message sent as the very first framed message after connection (#555). +/// Client reads this before entering the normal tick loop and validates +/// `protocol_version` against its own `PROTOCOL_VERSION` constant. +/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HandshakeMessage { + /// Must match client's PROTOCOL_VERSION or the client should disconnect. + pub protocol_version: u8, +} /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -39,10 +49,11 @@ pub const PROTOCOL_VERSION: u8 = 14; /// v14 adds: poi_list (#151, discovered POIs for minimap rendering), /// examine_result (#242, character-filtered examine observation text), /// player_knowledge (#264, partial KG dump for journal/knowledge panel). +/// v15 adds: save_result (#553, save/load operation result for client confirmation). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 14. + /// Protocol version for forward compatibility. Current: 15. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -110,11 +121,6 @@ pub struct ObserverSnapshot { /// Client shows follow indicator with distance, LOS, and tension. #[serde(default)] pub follow_state: Option, - /// 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, /// Character pressure state for client HUD widget (#248). /// Present when pressure is non-zero. Client renders tension indicator. #[serde(default)] @@ -140,6 +146,11 @@ pub struct ObserverSnapshot { /// Client renders as a read-only journal grouped by entity. #[serde(default, skip_serializing_if = "Option::is_none")] pub player_knowledge: Option, + /// Result of the most recently completed save or load (#553, D-085). + /// Present for exactly one tick after the operation completes. + /// Client shows a confirmation toast (success) or error modal (failure). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub save_result: Option, } /// Game time data for client display (D-031) @@ -414,6 +425,14 @@ pub enum PlayerAction { target_entity_id: u64, response_id: String, }, + /// Save the current game state to `path` (#553, D-085). + /// Client sends this when the player activates the save UI. + /// Server executes save_to_file and sends SaveLoadResultWire confirmation. + SaveGame { path: String }, + /// Load a previously saved game from `path` (#553, D-085). + /// Client sends this when the player selects a save file to load. + /// Server executes load_from_file and sends SaveLoadResultWire confirmation. + LoadGame { path: String }, } impl PlayerAction { @@ -642,8 +661,65 @@ pub struct KnownFactWire { pub acquired_tick: u64, } +/// Save/load operation result for client confirmation (#553, D-085). +/// +/// Included in `ObserverSnapshot.save_result` for exactly one tick after the +/// operation completes. `success=false` carries a human-readable `error` string. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveLoadResultWire { + /// Whether the save or load succeeded. + pub success: bool, + /// "save" or "load" — identifies which operation completed. + pub kind: String, + /// Error message if `success` is false. None on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { pub snapshot: Option, + /// Pending save/load result, consumed once by `compute_observer_snapshot` (#553). + pub pending_save_result: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handshake_message_roundtrip() { + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, msg); + assert_eq!(decoded.protocol_version, PROTOCOL_VERSION); + } + + #[test] + fn handshake_message_rejects_wrong_version() { + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + // Simulate client-side validation: version mismatch should be detectable + let wrong_version = PROTOCOL_VERSION.wrapping_add(1); + assert_ne!(decoded.protocol_version, wrong_version); + } + + #[test] + fn handshake_is_distinct_from_snapshot() { + // HandshakeMessage and ObserverSnapshot are different types on the wire. + // A HandshakeMessage should NOT deserialize as an ObserverSnapshot. + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let result = rmp_serde::from_slice::(&bytes); + assert!(result.is_err(), "HandshakeMessage must not deserialize as ObserverSnapshot"); + } } diff --git a/server/src/knowledge/registry.rs b/server/src/knowledge/registry.rs index 915d308d2..7f67b32b1 100644 --- a/server/src/knowledge/registry.rs +++ b/server/src/knowledge/registry.rs @@ -83,6 +83,38 @@ impl EntityRegistry { self.next_id = target; } + /// Register an entity with a specific pre-existing StableId (used during save/load). + /// + /// Unlike `register`, this does NOT advance `next_id`. After bulk-registering + /// all restored entities, call `advance_past(max_stable_id)` so future `register()` + /// calls produce IDs that don't conflict with the restored set. + /// + /// No-op if the entity is already mapped to the same `stable_id`. + /// Panics in debug builds if `stable_id` is already mapped to a different entity. + pub fn register_existing(&mut self, entity: Entity, stable_id: StableId) { + if let Some(&existing) = self.by_stable_id.get(&stable_id) { + debug_assert_eq!( + existing, entity, + "register_existing: StableId {:?} already mapped to a different entity", + stable_id + ); + return; + } + self.by_stable_id.insert(stable_id, entity); + self.by_entity.insert(entity, stable_id); + } + + /// Advance `next_id` past `id` so future `register()` calls don't conflict. + /// + /// Unlike `reserve_up_to`, this never panics: if the counter is already past `id`, + /// this is a no-op. Use after `register_existing` bulk-load to position the counter. + pub fn advance_past(&mut self, id: u64) { + let target = id.saturating_add(1); + if target > self.next_id { + self.next_id = target; + } + } + /// Number of registered entities. pub fn len(&self) -> usize { self.by_entity.len() diff --git a/server/src/main.rs b/server/src/main.rs index 8913c7b54..61b66fd2d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -11,7 +11,7 @@ use bevy_app::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use settled_reach_server::bridge::tcp::TcpBridge; -use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::bridge::{BridgePlugin, BridgeResource, HandshakeState, ServerRunning}; use settled_reach_server::simulation::SimulationPlugin; fn main() { @@ -121,7 +121,17 @@ fn main() { tracing::error!("Failed to accept: {}", e); std::process::exit(1); }); - tracing::info!("Client connected, initializing simulation"); + tracing::info!("Client connected, sending protocol handshake"); + + // Protocol handshake: first framed message on the wire (#555). + // Client reads this and validates protocol_version before sending any input. + use settled_reach_server::bridge::SimBridge; + bridge.send_handshake().unwrap_or_else(|e| { + tracing::error!("Failed to send handshake: {}", e); + std::process::exit(1); + }); + + tracing::info!("Handshake sent, initializing simulation"); // RNG seed: test-mode defaults to 42 for deterministic replay let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 }); @@ -138,6 +148,7 @@ fn main() { }); app.add_plugins(settled_reach_server::content::ContentPlugin); app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(HandshakeState::Complete); // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index bf82678bf..6d07f11fa 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -378,6 +378,9 @@ pub fn compute_observer_snapshot( None }; + // Consume pending save/load result for this tick (#553). + let save_result = buffer.pending_save_result.take(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -396,15 +399,21 @@ 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, - examine_result: None, // Populated by examine system when #242 lands + examine_result: examine_result.map(|e| { + crate::bridge::types::ExamineResultWire { + entity_id: e.target_entity_id, + text: e.text, + confidence: crate::bridge::types::KnowledgeConfidence::Direct, + } + }), player_knowledge, + save_result, }); } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index a04c233a3..b72a7259f 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -9,6 +9,7 @@ use crate::simulation::inventory::{ find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, }; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; +use crate::simulation::save_io::{SaveLoadCommand, SaveLoadPending}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; @@ -74,7 +75,8 @@ impl InputQueue { } /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. -/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424). +/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424), +/// and save/load commands (#553). #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut, @@ -94,6 +96,7 @@ pub fn process_player_input( all_positions: Query<&TilePosition>, reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option>, + mut save_load: Option>, ) { let current_tick = time.tick; let paused = time.paused(); @@ -104,12 +107,15 @@ pub fn process_player_input( for input in inputs { // Discard all gameplay actions while paused (D-052, R2-OQ-01). - // Only Pause/Unpause/TeleportToHub are processed — everything else is discarded. - // TeleportToHub is exempted because it's a Gauntlet QA action (#491). + // SaveGame/LoadGame are also exempted — saving while paused is valid (#553). if paused && !matches!( input.action, - PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub + PlayerAction::Pause + | PlayerAction::Unpause + | PlayerAction::TeleportToHub + | PlayerAction::SaveGame { .. } + | PlayerAction::LoadGame { .. } ) { continue; @@ -306,6 +312,36 @@ pub fn process_player_input( PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } + PlayerAction::SaveGame { ref path } => { + if let Some(ref mut sl) = save_load { + if sl.pending.is_some() { + tracing::warn!( + "SaveGame overwrites already-pending save/load command (dropped)" + ); + } + sl.pending = Some(SaveLoadCommand::Save { + path: std::path::PathBuf::from(path), + }); + tracing::info!("SaveGame queued: {:?}", path); + } else { + tracing::warn!("SaveGame received but SaveLoadPending resource not registered"); + } + } + PlayerAction::LoadGame { ref path } => { + if let Some(ref mut sl) = save_load { + if sl.pending.is_some() { + tracing::warn!( + "LoadGame overwrites already-pending save/load command (dropped)" + ); + } + sl.pending = Some(SaveLoadCommand::Load { + path: std::path::PathBuf::from(path), + }); + tracing::info!("LoadGame queued: {:?}", path); + } else { + tracing::warn!("LoadGame received but SaveLoadPending resource not registered"); + } + } } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 43edc59f5..de0f3436b 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -22,6 +22,7 @@ pub mod poi; pub mod poi_discovery; pub mod pressure; pub mod rng; +pub mod save_io; pub mod save_state; pub mod sound; pub mod spatial; @@ -43,6 +44,7 @@ impl Plugin for SimulationPlugin { app.init_resource::() .insert_resource(rng::SimRng::new(0)) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -61,6 +63,12 @@ impl Plugin for SimulationPlugin { Update, ( input::process_player_input, + // execute_save_load is an exclusive system (takes &mut World). + // Must run after process_player_input (which queues the command) + // and before compute_observer_snapshot (which consumes the result). + save_io::execute_save_load + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), pathfinding::compute_paths.after(input::process_player_input), path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs new file mode 100644 index 000000000..47b155b57 --- /dev/null +++ b/server/src/simulation/save_io.rs @@ -0,0 +1,656 @@ +// Save/load ECS extraction (#553) +// Implements D-020 MessagePack format for save files, D-010 determinism. +// +// Two entry points: +// save_to_file: queries ECS, builds SaveStateV1, writes MessagePack to path. +// load_from_file: reads path, deserialises SaveStateV1, re-injects ECS state. +// +// IPC: SaveGame / LoadGame PlayerAction variants queue commands here. +// execute_save_load: exclusive system that drains the queue and writes the result +// to SnapshotBuffer.pending_save_result for client feedback. + +use std::path::{Path, PathBuf}; + +use bevy_ecs::prelude::*; +use thiserror::Error; + +use crate::bridge::types::SaveLoadResultWire; +use crate::bridge::types::SnapshotBuffer; +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::EntityRegistry; +use crate::npc::Npc; +use crate::npc::relationships::RelationshipGraph; +use crate::simulation::movement::PlayerCharacter; +use crate::simulation::rng::SimRng; +use crate::simulation::save_state::{ + deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION, +}; +use crate::simulation::tier::BackgroundSim; +use crate::simulation::time::SimulationTime; + +/// Errors from save/load operations (#553). +#[derive(Debug, Error)] +pub enum SaveLoadError { + #[error("I/O error: {0}")] + Io(String), + #[error("serialization error: {0}")] + Serialize(String), + #[error("deserialization error: {0}")] + Deserialize(String), + #[error("format version mismatch: expected {expected}, found {found}")] + VersionMismatch { expected: u8, found: u8 }, +} + +/// A queued save or load command (#553). +#[derive(Debug, Clone)] +pub enum SaveLoadCommand { + Save { path: PathBuf }, + Load { path: PathBuf }, +} + +/// Pending save/load command resource (#553). +/// +/// `process_player_input` queues commands here when it encounters +/// `PlayerAction::SaveGame` or `PlayerAction::LoadGame`. The +/// `execute_save_load` exclusive system drains this queue each tick. +#[derive(Resource, Debug, Default)] +pub struct SaveLoadPending { + /// Pending command (at most one; new commands overwrite pending ones). + pub pending: Option, +} + +/// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553). +/// +/// Queries all NPC entities, the player knowledge graph, global relationship graph, +/// simulation time, and RNG seed. Builds `SaveStateV1` and writes to disk. +/// +/// NPC states are sorted by `stable_id` ascending for determinism (D-010). +/// NPCs without `StableEntityId` trigger a panic (caller invariant — all live +/// NPCs must be registered before save). +/// +/// # Errors +/// `SaveLoadError::Io` on filesystem failure. +/// `SaveLoadError::Serialize` on MessagePack encoding failure. +pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { + // Simulation clock + let (tick, tick_rate) = { + let t = world.resource::(); + (t.tick, t.tick_rate) + }; + + // RNG seed for deterministic replay (D-010) + let seed = world.resource::().seed(); + + // Player knowledge graph — the observer's epistemics at save time + let player_knowledge = { + let mut q = world.query_filtered::<&KnowledgeGraph, With>(); + q.single(world).cloned().unwrap_or_else(|_| { + tracing::warn!("save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph"); + KnowledgeGraph::new() + }) + }; + + // Global NPC social web + let relationship_graph = world.resource::().clone(); + + // Per-NPC states: collect then sort by stable_id (D-010 determinism) + let npc_entities: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + let mut npc_states: Vec<_> = npc_entities + .iter() + .map(|&entity| serialize_npc_to_frozen(entity, world)) + .collect(); + npc_states.sort_by_key(|s| s.stable_id.0); + + let npc_count = npc_states.len(); + let state = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick, + tick_rate, + seed, + player_knowledge, + relationship_graph, + npc_states, + }; + + let bytes = state + .to_bytes() + .map_err(|e| SaveLoadError::Serialize(e.to_string()))?; + + std::fs::write(path, &bytes).map_err(|e| SaveLoadError::Io(e.to_string()))?; + + tracing::info!( + "save_to_file: {:?} (tick={}, npcs={}, {} bytes)", + path, + tick, + npc_count, + bytes.len() + ); + Ok(()) +} + +/// Read `path`, deserialise `SaveStateV1`, and re-inject state into the ECS (#553). +/// +/// Steps: +/// 1. Read and deserialise bytes; reject if `format_version != SAVE_FORMAT_VERSION`. +/// 2. Despawn all existing NPC entities and unregister them from `EntityRegistry`. +/// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with +/// `register_existing`; insert `BackgroundSim` tier marker. +/// 4. Advance `EntityRegistry` counter past all restored IDs. +/// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources. +/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists. +/// +/// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses +/// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the +/// new `Entity` handles after re-spawn. +/// +/// # Errors +/// `SaveLoadError::Io` on filesystem failure. +/// `SaveLoadError::Deserialize` on MessagePack decoding failure. +/// `SaveLoadError::VersionMismatch` when the save file predates the current schema. +pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { + let bytes = std::fs::read(path).map_err(|e| SaveLoadError::Io(e.to_string()))?; + let state = + SaveStateV1::from_bytes(&bytes).map_err(|e| SaveLoadError::Deserialize(e.to_string()))?; + + if state.format_version != SAVE_FORMAT_VERSION { + return Err(SaveLoadError::VersionMismatch { + expected: SAVE_FORMAT_VERSION, + found: state.format_version, + }); + } + + let npc_count = state.npc_states.len(); + + // Despawn all existing NPC entities and clear their registry entries. + let npc_entities: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in npc_entities { + world.resource_mut::().unregister(entity); + world.despawn(entity); + } + + // Track the highest restored StableId so we can advance the counter. + let mut max_id: u64 = 0; + + // Re-spawn NPCs, assign tier marker, register pre-existing StableIds. + for npc_state in &state.npc_states { + let entity = deserialize_npc_from_frozen(npc_state, world); + + // Loaded NPCs start in BackgroundSim; the distance system promotes as needed. + world.entity_mut(entity).insert(BackgroundSim); + + let stable_id = npc_state.stable_id; + world + .resource_mut::() + .register_existing(entity, stable_id); + + max_id = max_id.max(stable_id.0); + } + + // Advance the registry counter past all restored IDs so future register() + // calls produce non-conflicting IDs. + if npc_count > 0 { + world.resource_mut::().advance_past(max_id); + } + + // Restore simulation resources. + world.insert_resource(state.relationship_graph); + { + let mut t = world.resource_mut::(); + t.tick = state.tick; + t.tick_rate = state.tick_rate; + } + world.insert_resource(SimRng::new(state.seed)); + + // Update the player entity's KnowledgeGraph if a player exists. + let player_entity = { + let mut q = world.query_filtered::>(); + q.single(world).ok() + }; + if let Some(player_entity) = player_entity { + world + .entity_mut(player_entity) + .insert(state.player_knowledge); + } + + tracing::info!( + "load_from_file: {:?} (tick={}, npcs={})", + path, + state.tick, + npc_count, + ); + Ok(()) +} + +/// Exclusive system: drain `SaveLoadPending` and execute queued save/load (#553). +/// +/// Runs each tick, after `process_player_input`. If a command is pending, +/// executes it and writes `SaveLoadResultWire` to `SnapshotBuffer.pending_save_result` +/// for consumption by `compute_observer_snapshot` the same tick. +pub fn execute_save_load(world: &mut World) { + // Take the pending command (releases the borrow before we use world again). + let command = { + let mut pending = world.resource_mut::(); + pending.pending.take() + }; + + let Some(command) = command else { + return; + }; + + let (kind_str, result) = match &command { + SaveLoadCommand::Save { path } => { + let r = save_to_file(path, world); + ("save", r) + } + SaveLoadCommand::Load { path } => { + let r = load_from_file(path, world); + ("load", r) + } + }; + + let wire_result = match result { + Ok(()) => { + tracing::info!("execute_save_load: {} completed", kind_str); + SaveLoadResultWire { + success: true, + kind: kind_str.to_string(), + error: None, + } + } + Err(ref e) => { + tracing::error!("execute_save_load: {} failed: {}", kind_str, e); + SaveLoadResultWire { + success: false, + kind: kind_str.to_string(), + error: Some(e.to_string()), + } + } + }; + + // Write result to SnapshotBuffer for client feedback (one tick only — consumed by + // compute_observer_snapshot via pending_save_result.take()). + if let Some(mut buf) = world.get_resource_mut::() { + buf.pending_save_result = Some(wire_result); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::graph::KnowledgeGraph; + use crate::knowledge::registry::{EntityRegistry, StableEntityId}; + use crate::knowledge::types::StableId; + use crate::npc::Npc; + use crate::npc::relationships::RelationshipGraph; + use crate::simulation::movement::TilePosition; + use crate::simulation::rng::SimRng; + use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION}; + use crate::simulation::time::{SimulationTime, TickRate}; + use bevy_ecs::world::World; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_path() -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id)) + } + + fn minimal_world() -> World { + let mut w = World::new(); + w.insert_resource(SimulationTime::default()); + w.insert_resource(SimRng::new(42)); + w.insert_resource(RelationshipGraph::new()); + w.init_resource::(); + w + } + + fn spawn_test_npc(world: &mut World, stable_id: u64) -> Entity { + world + .spawn(( + Npc, + StableEntityId(StableId(stable_id)), + TilePosition::new(stable_id as i32, 0, 0), + )) + .id() + } + + // ----------------------------------------------------------------------- + // save_to_file + // ----------------------------------------------------------------------- + + #[test] + fn save_to_file_creates_valid_msgpack() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + spawn_test_npc(&mut world, 2); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save should succeed"); + + let bytes = std::fs::read(&path).expect("file should exist"); + let state = SaveStateV1::from_bytes(&bytes).expect("bytes must be valid msgpack"); + assert_eq!(state.format_version, SAVE_FORMAT_VERSION); + assert_eq!(state.npc_states.len(), 2); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_sorts_npc_states_by_stable_id() { + let mut world = minimal_world(); + // Spawn in reverse order — save should still sort ascending + spawn_test_npc(&mut world, 50); + spawn_test_npc(&mut world, 10); + spawn_test_npc(&mut world, 30); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save should succeed"); + + let bytes = std::fs::read(&path).expect("read saved file"); + let state = SaveStateV1::from_bytes(&bytes).unwrap(); + let ids: Vec = state.npc_states.iter().map(|n| n.stable_id.0).collect(); + assert_eq!(ids, vec![10, 30, 50], "npc_states must be sorted by stable_id"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_preserves_tick_and_seed() { + let mut world = minimal_world(); + { + let mut t = world.resource_mut::(); + t.tick = 9999; + t.tick_rate = TickRate::Half; + } + world.insert_resource(SimRng::new(0xDEADBEEF)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + let bytes = std::fs::read(&path).unwrap(); + let state = SaveStateV1::from_bytes(&bytes).unwrap(); + assert_eq!(state.tick, 9999); + assert_eq!(state.tick_rate, TickRate::Half); + assert_eq!(state.seed, 0xDEADBEEF); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_returns_io_error_on_bad_path() { + let mut world = minimal_world(); + let bad_path = std::path::Path::new("/nonexistent/directory/save.msgpack"); + let result = save_to_file(bad_path, &mut world); + assert!( + matches!(result, Err(SaveLoadError::Io(_))), + "expected Io error for bad path" + ); + } + + // ----------------------------------------------------------------------- + // load_from_file + // ----------------------------------------------------------------------- + + #[test] + fn load_from_file_restores_npc_count() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + spawn_test_npc(&mut world, 2); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Spawn an extra NPC — loading should despawn the old NPCs and restore exactly 2 + spawn_test_npc(&mut world, 99); + let pre_load_count = { + let mut q = world.query_filtered::>(); + q.iter(&world).count() + }; + assert_eq!(pre_load_count, 3, "three NPCs before load"); + + load_from_file(&path, &mut world).expect("load"); + + let post_load_count = { + let mut q = world.query_filtered::>(); + q.iter(&world).count() + }; + assert_eq!(post_load_count, 2, "exactly the two saved NPCs after load"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_restores_stable_ids_in_registry() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 10); + spawn_test_npc(&mut world, 20); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + let registry = world.resource::(); + assert!( + registry.to_entity(&StableId(10)).is_some(), + "StableId(10) must be in registry after load" + ); + assert!( + registry.to_entity(&StableId(20)).is_some(), + "StableId(20) must be in registry after load" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_restores_tick_and_seed() { + let mut world = minimal_world(); + { + let mut t = world.resource_mut::(); + t.tick = 5000; + } + world.insert_resource(SimRng::new(12345)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Change time and seed, then load + { + let mut t = world.resource_mut::(); + t.tick = 1; + } + world.insert_resource(SimRng::new(0)); + + load_from_file(&path, &mut world).expect("load"); + + let t = world.resource::(); + assert_eq!(t.tick, 5000, "tick restored from save"); + assert_eq!( + world.resource::().seed(), + 12345, + "seed restored from save" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_rejects_wrong_format_version() { + // Craft a save with a wrong format_version + let bad_state = SaveStateV1 { + format_version: 0xFF, // deliberately wrong + tick: 0, + tick_rate: TickRate::Full, + seed: 0, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![], + }; + let bytes = bad_state.to_bytes().expect("serialize"); + let path = temp_path(); + std::fs::write(&path, &bytes).expect("write"); + + let mut world = minimal_world(); + let result = load_from_file(&path, &mut world); + assert!( + matches!(result, Err(SaveLoadError::VersionMismatch { .. })), + "expected VersionMismatch error, got {:?}", + result + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_returns_io_error_for_missing_file() { + let mut world = minimal_world(); + let missing = std::path::Path::new("/tmp/settled_reach_nonexistent_42.msgpack"); + let result = load_from_file(missing, &mut world); + assert!( + matches!(result, Err(SaveLoadError::Io(_))), + "expected Io error for missing file" + ); + } + + #[test] + fn load_from_file_assigns_background_sim_tier() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + let has_background: bool = { + let mut q = world.query_filtered::, With)>(); + q.iter(&world).count() > 0 + }; + assert!( + has_background, + "loaded NPC should be in BackgroundSim tier" + ); + + let _ = std::fs::remove_file(&path); + } + + // ----------------------------------------------------------------------- + // execute_save_load + // ----------------------------------------------------------------------- + + #[test] + fn execute_save_load_noop_when_no_pending() { + let mut world = minimal_world(); + world.init_resource::(); + world.init_resource::(); + + execute_save_load(&mut world); + + // No result written when no pending command + let buf = world.resource::(); + assert!( + buf.pending_save_result.is_none(), + "no pending_save_result when no command was queued" + ); + } + + #[test] + fn execute_save_load_writes_success_result() { + let mut world = minimal_world(); + world.init_resource::(); + + let path = temp_path(); + world.insert_resource(SaveLoadPending { + pending: Some(SaveLoadCommand::Save { path: path.clone() }), + }); + + execute_save_load(&mut world); + + let buf = world.resource::(); + let result = buf + .pending_save_result + .as_ref() + .expect("result must be written after execute"); + assert!(result.success, "save should succeed"); + assert_eq!(result.kind, "save"); + assert!(result.error.is_none()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn execute_save_load_writes_error_result_on_bad_path() { + let mut world = minimal_world(); + world.init_resource::(); + + world.insert_resource(SaveLoadPending { + pending: Some(SaveLoadCommand::Save { + path: PathBuf::from("/nonexistent/dir/save.msgpack"), + }), + }); + + execute_save_load(&mut world); + + let buf = world.resource::(); + let result = buf + .pending_save_result + .as_ref() + .expect("result must be written even on failure"); + assert!(!result.success, "save should fail with bad path"); + assert_eq!(result.kind, "save"); + assert!(result.error.is_some(), "error message should be present"); + } + + // ----------------------------------------------------------------------- + // Overwrite behaviour + // ----------------------------------------------------------------------- + + /// When two commands arrive in the same tick, the second overwrites the first. + /// The warn! in process_player_input fires; here we just confirm last-write-wins. + #[test] + fn pending_command_overwrite_last_write_wins() { + let mut pending = SaveLoadPending::default(); + + pending.pending = Some(SaveLoadCommand::Save { + path: PathBuf::from("/tmp/first.msgpack"), + }); + // Overwrite with a Load command + pending.pending = Some(SaveLoadCommand::Load { + path: PathBuf::from("/tmp/second.msgpack"), + }); + + match pending.pending.unwrap() { + SaveLoadCommand::Load { ref path } => { + assert_eq!(path.to_str().unwrap(), "/tmp/second.msgpack"); + } + other => panic!("expected Load, got {:?}", other), + } + } + + // ----------------------------------------------------------------------- + // SaveLoadError display + // ----------------------------------------------------------------------- + + #[test] + fn save_load_error_display() { + let e = SaveLoadError::Io("disk full".into()); + assert!(e.to_string().contains("disk full")); + + let e2 = SaveLoadError::VersionMismatch { + expected: 1, + found: 2, + }; + assert!(e2.to_string().contains("expected 1")); + assert!(e2.to_string().contains("found 2")); + } +} diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 672cd0681..c9d8a2936 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -2,7 +2,8 @@ //! //! `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. +//! defines the data model AND the per-NPC serialization primitives for tier +//! eviction freeze/thaw (#96). //! //! ## Write format: MessagePack //! @@ -34,12 +35,22 @@ //! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after //! reload; memory degrades naturally over time so reset-on-load is acceptable) +use bevy_ecs::entity::Entity; +use bevy_ecs::world::World; use serde::{Deserialize, Serialize}; use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::StableId; -use crate::npc::{SecretSeverity, Relationships}; +use crate::npc::{ + CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc, + PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem, + ToleranceThreshold, Want, WantKind, +}; +use crate::npc::awareness::PlayerAwareness; +use crate::npc::mood::MoodState; use crate::npc::relationships::RelationshipGraph; +use crate::npc::vision::{NpcMemory, NpcVisionState}; use crate::simulation::movement::TilePosition; use crate::simulation::time::TickRate; @@ -76,9 +87,36 @@ pub struct SaveStateV1 { /// 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. +/// Two usage contexts: +/// 1. **Whole-game save** (`SaveStateV1.npc_states`): populated by #553 ECS extraction. +/// Only the core axis fields need to be populated for this use case. +/// 2. **Tier eviction freeze** (produced by `serialize_npc_to_frozen`): captures ALL +/// components needed for full NPC reconstruction from `StateSaved` tier. +/// The extended optional fields (#96) carry all 10 D-024 axes. +/// +/// All fields added post-#256 use `#[serde(default)]` for forward compatibility +/// with older save files that predate these fields. +/// +/// ## D-024 axis coverage +/// | Axis | Field | Status | +/// |------|-------|--------| +/// | 1: Want | `want` | Full (optional for backward compat) | +/// | 2: Secret | `secret_severity` (legacy) + `secret` | Full | +/// | 3: Relationships | `relationships` | Full | +/// | 4: Tolerance | `current_stress` + `tolerance_threshold` | Full | +/// | 5: Daily routine | `routine` | Full (optional) | +/// | 6: Information inventory | `information_inventory` | Full (optional) | +/// | 7: Contentment | `contentment` | Full | +/// | Supporting 1: Personality | `personality_traits` | Full (optional) | +/// | Supporting 2: Tells | `tell_system` | Full (optional) | +/// | Supporting 3: Skills | `skill_set` + `combat_capability` | Full (optional) | +/// +/// ## Components intentionally NOT serialized +/// - `NpcVisionState`: runtime LOS state, reset to default on reactivation +/// - `NpcMemory`: stale inferences would be wrong after reload (intentional drop) +/// - `PlayerAwareness`: runtime derived state, reset to default on reactivation +/// - `AnimationTier`: resets to `Tier1` on reactivation (no persistent state) +/// - `RoutineDeviation`: transient event marker, acceptable to drop on reload #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NpcSaveState { /// Stable entity identifier (survives serialization — D-020). @@ -86,7 +124,8 @@ pub struct NpcSaveState { /// Last known tile position. pub position: TilePosition, - // Axis 2: Secret severity (description is regenerated from content on load) + // Axis 2: Secret severity (legacy field — description regenerated from content on load). + // Kept for backward compatibility. Prefer `secret` field when doing full reconstruction. pub secret_severity: SecretSeverity, // Axis 3: Per-NPC relationship slots pub relationships: Option, @@ -99,6 +138,52 @@ pub struct NpcSaveState { /// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG). pub knowledge_graph: Option, + + // --- Full reconstruction fields (added #96, for tier eviction freeze) --- + // All fields below use serde(default) for backward compatibility with saves + // created before #96 shipped. + + /// Axis 1: Want (primary drive, intensity, and description). + #[serde(default)] + pub want: Option, + + /// Axis 2: Full secret (description + known_by list). + /// Supersedes `secret_severity` for full reconstruction. + #[serde(default)] + pub secret: Option, + + /// Axis 5: Daily routine (phase → location schedule). + #[serde(default)] + pub routine: Option, + + /// Axis 6: Information inventory (facts this NPC carries). + #[serde(default)] + pub information_inventory: Option, + + /// Supporting axis 1: Personality traits (2–3 traits, no contradictory pairs). + #[serde(default)] + pub personality_traits: Option, + + /// Supporting axis 2: Tell system (behavioral tells tied to stress/personality). + #[serde(default)] + pub tell_system: Option, + + /// Supporting axis 3: Skill set (proficiency BTreeMap). + #[serde(default)] + pub skill_set: Option, + + /// Optional combat capability (only present for combat-trained NPCs). + #[serde(default)] + pub combat_capability: Option, + + /// Mood state at save time. Derived from stress but worth preserving across + /// tier transitions to avoid jarring state resets on reactivation. + #[serde(default)] + pub mood_state: Option, + + /// Job performance score — drifts over time, persist across tier transitions. + #[serde(default)] + pub job_performance: Option, } impl SaveStateV1 { @@ -113,6 +198,166 @@ impl SaveStateV1 { } } +// --------------------------------------------------------------------------- +// Per-NPC tier eviction serialization primitives (#96) +// --------------------------------------------------------------------------- + +/// Serialize a live NPC entity to a `NpcSaveState` frozen struct. +/// +/// Used by the tier eviction system when demoting an entity to `StateSaved`: +/// instead of keeping all ECS components live, the entity is frozen and despawned. +/// The caller should despawn the entity after calling this function. +/// +/// **Caller invariant:** The entity must have a `StableEntityId` component. +/// All other components are optional — missing components produce sensible defaults +/// in the output (and will be reconstructed as defaults by `deserialize_npc_from_frozen`). +/// +/// # Panics +/// Panics if the entity has no `StableEntityId` component. +pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState { + let position = world + .get::(entity) + .copied() + .unwrap_or_else(|| TilePosition::new(0, 0, 0)); + + let stable_id = world + .get::(entity) + .map(|s| s.0) + .expect("NPC entity must have StableEntityId before serialization (#96)"); + + let secret = world.get::(entity).cloned(); + let secret_severity = secret + .as_ref() + .map(|s| s.severity) + .unwrap_or(SecretSeverity::Minor); + + let (current_stress, tolerance_threshold) = world + .get::(entity) + .map(|t| (t.current_stress, t.threshold)) + .unwrap_or((0, 50)); + + NpcSaveState { + stable_id, + position, + secret_severity, + relationships: world.get::(entity).cloned(), + current_stress, + tolerance_threshold, + contentment: world + .get::(entity) + .map(|c| c.level) + .unwrap_or(0), + knowledge_graph: world.get::(entity).cloned(), + want: world.get::(entity).cloned(), + secret, + routine: world.get::(entity).cloned(), + information_inventory: world.get::(entity).cloned(), + personality_traits: world.get::(entity).cloned(), + tell_system: world.get::(entity).cloned(), + skill_set: world.get::(entity).cloned(), + combat_capability: world.get::(entity).cloned(), + mood_state: world.get::(entity).cloned(), + job_performance: world.get::(entity).cloned(), + } +} + +/// Deserialize a frozen `NpcSaveState` and re-spawn a full NPC entity. +/// +/// Used by the tier eviction system when reactivating an entity from `StateSaved`. +/// Reconstructs all D-024 axis components from the frozen state. +/// +/// **Caller responsibilities after calling this function:** +/// 1. Register the returned `Entity` with `EntityRegistry` (StableId→Entity mapping). +/// 2. Assign the appropriate tier marker (`ActiveSim` or `BackgroundSim`). +/// +/// Optional fields that are absent in `state` are reconstructed with sensible defaults: +/// - `Want`: defaults to `Safety` at intensity 5 (conservative non-disruptive default) +/// - `Secret`: reconstructed from `secret_severity` with empty description +/// - `MoodState`, `JobPerformance`: their `Default` implementations +/// +/// Components excluded from reconstruction (see `NpcSaveState` doc for rationale): +/// `NpcVisionState`, `NpcMemory`, `PlayerAwareness` are reset to their `Default` states. +pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> Entity { + let want = state.want.clone().unwrap_or(Want { + primary: WantKind::Safety, // conservative fallback — see flag comment above + intensity: 5, + description: String::new(), + }); + + let secret = state.secret.clone().unwrap_or(crate::npc::Secret { + description: String::new(), + severity: state.secret_severity, + known_by: vec![], + }); + + let relationships = state + .relationships + .clone() + .unwrap_or(Relationships { entries: vec![] }); + + let tolerance = ToleranceThreshold { + current_stress: state.current_stress, + threshold: state.tolerance_threshold, + }; + + let contentment = Contentment { + level: state.contentment, + }; + + let kg = state + .knowledge_graph + .clone() + .unwrap_or_else(KnowledgeGraph::new); + + // Spawn the entity with all required components. Tier marker (ActiveSim / + // BackgroundSim) is NOT added here — the caller assigns it after registration. + let entity = world + .spawn(( + Npc, + state.position, + StableEntityId(state.stable_id), + want, + secret, + relationships, + tolerance, + contentment, + kg, + state.mood_state.clone().unwrap_or_default(), + state.job_performance.clone().unwrap_or_default(), + // Runtime-computed components: reset to default on reactivation. + NpcVisionState::default(), + NpcMemory::default(), + PlayerAwareness::default(), + )) + .id(); + + // Optional axis components — insert only if present in frozen state. + { + let mut em = world.entity_mut(entity); + + if let Some(routine) = state.routine.clone() { + em.insert(routine); + } + if let Some(inventory) = state.information_inventory.clone() { + em.insert(inventory); + } + if let Some(traits) = state.personality_traits.clone() { + em.insert(traits); + } + if let Some(tells) = state.tell_system.clone() { + em.insert(tells); + } + if let Some(skills) = state.skill_set.clone() { + em.insert(skills); + } + if let Some(combat) = state.combat_capability.clone() { + em.insert(combat); + } + } + + entity +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -121,6 +366,7 @@ impl SaveStateV1 { mod tests { use super::*; use crate::knowledge::graph::KnowledgeGraph; + use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId}; use crate::npc::relationships::RelationshipGraph; use crate::simulation::movement::TilePosition; @@ -193,6 +439,16 @@ mod tests { tolerance_threshold: 80, contentment: -15, knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, }, NpcSaveState { stable_id: StableId(202), @@ -203,6 +459,16 @@ mod tests { tolerance_threshold: 60, contentment: 30, knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, }, ]; @@ -301,6 +567,16 @@ mod tests { tolerance_threshold: 50, contentment: 0, knowledge_graph: Some(npc_kg), + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, }]; let bytes = state.to_bytes().expect("serialize"); @@ -317,4 +593,202 @@ mod tests { // Document the version explicitly so CI catches unintentional bumps. assert_eq!(SAVE_FORMAT_VERSION, 1); } + + // ----------------------------------------------------------------------- + // Tier eviction serialization primitives (#96) + // ----------------------------------------------------------------------- + + fn spawn_minimal_npc(world: &mut World, stable_id: StableId) -> Entity { + use crate::npc::{ + Contentment, Relationships, Secret, SecretSeverity, ToleranceThreshold, Want, WantKind, + }; + use crate::npc::mood::MoodState; + use crate::npc::vision::{NpcMemory, NpcVisionState}; + use crate::npc::awareness::PlayerAwareness; + + world + .spawn(( + Npc, + StableEntityId(stable_id), + TilePosition::new(5, 10, 0), + Want { + primary: WantKind::Safety, + intensity: 7, + description: "wants safety".into(), + }, + Secret { + description: "has a minor secret".into(), + severity: SecretSeverity::Minor, + known_by: vec![], + }, + Relationships { entries: vec![] }, + ToleranceThreshold { + current_stress: 30, + threshold: 70, + }, + Contentment { level: 15 }, + MoodState::default(), + crate::npc::JobPerformance::default(), + KnowledgeGraph::new(), + NpcVisionState::default(), + NpcMemory::default(), + PlayerAwareness::default(), + )) + .id() + } + + #[test] + fn serialize_npc_to_frozen_captures_stable_id_and_position() { + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(42)); + + let frozen = serialize_npc_to_frozen(entity, &world); + + assert_eq!(frozen.stable_id, StableId(42)); + assert_eq!(frozen.position, TilePosition::new(5, 10, 0)); + } + + #[test] + fn serialize_npc_to_frozen_captures_axes() { + use crate::npc::SecretSeverity; + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(1)); + + let frozen = serialize_npc_to_frozen(entity, &world); + + assert_eq!(frozen.secret_severity, SecretSeverity::Minor); + assert_eq!(frozen.current_stress, 30); + assert_eq!(frozen.tolerance_threshold, 70); + assert_eq!(frozen.contentment, 15); + + // Full optional axes should be populated when components exist + assert!(frozen.want.is_some(), "want should be captured"); + assert!(frozen.secret.is_some(), "secret should be captured"); + } + + #[test] + fn serialize_deserialize_roundtrip_produces_identical_component_values() { + // Spec (#96): serialize + deserialize produces an entity with identical values. + let mut world = World::new(); + let original = spawn_minimal_npc(&mut world, StableId(77)); + + // Serialize + let frozen = serialize_npc_to_frozen(original, &world); + + // Deserialize into a new entity + let restored = deserialize_npc_from_frozen(&frozen, &mut world); + + // Verify StableEntityId matches + let orig_stable = world.get::(original).unwrap().0; + let rest_stable = world.get::(restored).unwrap().0; + assert_eq!(orig_stable, rest_stable, "StableId must match"); + + // Position + let orig_pos = world.get::(original).copied().unwrap(); + let rest_pos = world.get::(restored).copied().unwrap(); + assert_eq!(orig_pos, rest_pos, "position must match"); + + // Tolerance + let orig_tol = world.get::(original).cloned().unwrap(); + let rest_tol = world.get::(restored).cloned().unwrap(); + assert_eq!(orig_tol.current_stress, rest_tol.current_stress); + assert_eq!(orig_tol.threshold, rest_tol.threshold); + + // Contentment + let orig_con = world.get::(original).cloned().unwrap(); + let rest_con = world.get::(restored).cloned().unwrap(); + assert_eq!(orig_con.level, rest_con.level, "contentment must match"); + + // Want + let orig_want = world.get::(original).cloned().unwrap(); + let rest_want = world.get::(restored).cloned().unwrap(); + assert_eq!(orig_want.primary, rest_want.primary, "want.primary must match"); + assert_eq!(orig_want.intensity, rest_want.intensity, "want.intensity must match"); + + // Secret severity + let orig_secret = world.get::(original).cloned().unwrap(); + let rest_secret = world.get::(restored).cloned().unwrap(); + assert_eq!(orig_secret.severity, rest_secret.severity, "secret severity must match"); + } + + #[test] + fn deserialize_npc_without_optional_axes_uses_safe_defaults() { + // Spec (#96): optional fields absent in frozen state produce sensible defaults. + use crate::npc::SecretSeverity; + let frozen = NpcSaveState { + stable_id: StableId(999), + position: TilePosition::new(0, 0, 0), + secret_severity: SecretSeverity::Moderate, + relationships: None, + current_stress: 10, + tolerance_threshold: 50, + contentment: 0, + knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + }; + + let mut world = World::new(); + let entity = deserialize_npc_from_frozen(&frozen, &mut world); + + // Entity must exist with required components + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some(), "Want defaults to Safety"); + assert!(world.get::(entity).is_some(), "Secret built from secret_severity"); + + // Secret severity must be preserved from the legacy field + let secret = world.get::(entity).unwrap(); + assert_eq!(secret.severity, SecretSeverity::Moderate); + + // Optional axes absent in frozen state → not inserted or use defaults + assert!(world.get::(entity).is_none(), "routine absent when not frozen"); + } + + #[test] + fn frozen_npc_roundtrips_via_messagepack() { + // Spec (#96): NpcSaveState must survive MessagePack roundtrip. + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(55)); + let frozen = serialize_npc_to_frozen(entity, &world); + + // Wrap in SaveStateV1 for MessagePack encoding + let save = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 100, + tick_rate: TickRate::Full, + seed: 12, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![frozen], + }; + + let bytes = save.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "frozen NPC state must roundtrip via MessagePack"); + } + + #[test] + fn serialize_npc_panics_without_stable_entity_id() { + // Spec (#96): StableEntityId is required — missing it is a programmer error. + let mut world = World::new(); + let entity = world.spawn((Npc, TilePosition::new(0, 0, 0))).id(); + + // World doesn't implement UnwindSafe — wrap in AssertUnwindSafe. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + serialize_npc_to_frozen(entity, &world); + })); + assert!(result.is_err(), "must panic without StableEntityId"); + } } diff --git a/server/src/simulation/tier.rs b/server/src/simulation/tier.rs index bcb645bce..9a312fbd8 100644 --- a/server/src/simulation/tier.rs +++ b/server/src/simulation/tier.rs @@ -1,9 +1,21 @@ // Simulation tier system // Implements D-026: Active/Background/State-saved/Ungenerated tiers // Tier transitions based on player approach distance (#99). +// Scope tag system: NPCs with active scope tags stay pinned to ActiveSim (#98). +// Timestamp-based eviction: LRU eviction when ActiveSim exceeds capacity (#97). + +use std::collections::{BTreeSet, BinaryHeap}; +use std::cmp::Reverse; use bevy_app::prelude::*; use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::KnowledgeConfidence; +use crate::npc::{Npc, RelationshipKind}; +use crate::npc::relationships::RelationshipGraph; use crate::simulation::movement::{PlayerCharacter, TilePosition}; // --- Tier radius constants (D-026) --- @@ -38,20 +50,365 @@ pub struct BackgroundSim; #[derive(Component, Debug, Clone, Copy, Default)] pub struct StateSaved; +// --------------------------------------------------------------------------- +// Eviction system (D-026, #97) +// --------------------------------------------------------------------------- + +/// Maximum number of entities in `ActiveSim` before LRU eviction kicks in (D-026). +pub const ACTIVE_SIM_CAPACITY: usize = 80; + +/// Tracks the tick at which the player last interacted with or observed an NPC (#97). +/// Updated by `update_last_interaction_tick` when an NPC is in the player's LOS. +/// Used by `evict_excess_active` as the LRU sort key. +#[derive(Component, Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct LastInteractionTick(pub u64); + +/// Tracks current `ActiveSim` entity count vs. capacity (#97, D-026). +/// Updated each tick by `evict_excess_active`. +#[derive(Resource, Debug, Clone)] +pub struct SimSpacePressure { + /// Number of entities in `ActiveSim` at the start of the current tick's eviction pass. + /// + /// Set by `evict_excess_active` *before* any evictions run. Eviction commands are + /// deferred (applied after the system), so `active_count` reflects the pre-eviction + /// count, not the post-eviction count. Consumers (e.g., HUD pressure display) should + /// treat this as the high-water mark for the tick. + pub active_count: usize, + /// Capacity ceiling. + pub capacity: usize, +} + +impl Default for SimSpacePressure { + fn default() -> Self { + Self { + active_count: 0, + capacity: ACTIVE_SIM_CAPACITY, + } + } +} + +// --------------------------------------------------------------------------- +// Scope tag system (D-026, #98) +// --------------------------------------------------------------------------- + +/// Scope tag kinds: reasons why an NPC stays pinned to `ActiveSim` (D-026). +/// +/// Four variants track distinct reasons for pinning. An NPC may have multiple +/// reasons simultaneously — all are tracked in `ScopeTag`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum ScopeTagKind { + /// NPC is in the player's immediate neighborhood. + /// Set at session start for NPCs within `ACTIVE_RADIUS`. Managed by + /// `assign_neighborhood_tags_on_start` (deferred: future sprint). + Neighborhood, + /// NPC is involved in an active quest. + /// Reserved for the quest system (deferred: future sprint). + ActiveQuest, + /// NPC has a `Friend` or `Colleague` relationship with the player character. + /// Assigned by `assign_scope_tags` each tick from `RelationshipGraph`. + Colleague, + /// NPC is known to the player with confidence >= `KnowsOf`. + /// Assigned by `assign_scope_tags` each tick from player `KnowledgeGraph`. + KnownContact, +} + +/// Scope tag component: which scope tags currently apply to this NPC (D-026). +/// +/// NPCs carrying at least one scope tag are kept in `ActiveSim` regardless of +/// distance or LRU eviction pressure. `ScopePinned` is the eviction guard; +/// this component is the source of truth. +/// +/// Assignment: +/// - `KnownContact` and `Colleague`: recomputed by `assign_scope_tags` each tick. +/// - `Neighborhood`: set at session start (see `ScopeTagKind::Neighborhood`). +/// - `ActiveQuest`: reserved for future quest system. +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScopeTag { + pub tags: BTreeSet, +} + +impl ScopeTag { + /// Create a `ScopeTag` with a single initial kind. + pub fn with(kind: ScopeTagKind) -> Self { + let mut tags = BTreeSet::new(); + tags.insert(kind); + Self { tags } + } + + /// Add a scope tag kind. + pub fn add(&mut self, kind: ScopeTagKind) { + self.tags.insert(kind); + } + + /// Remove a scope tag kind. + pub fn remove(&mut self, kind: ScopeTagKind) { + self.tags.remove(&kind); + } + + /// True if this NPC carries at least one scope tag. + pub fn is_pinned(&self) -> bool { + !self.tags.is_empty() + } + + /// True if this specific kind is present. + pub fn contains(&self, kind: ScopeTagKind) -> bool { + self.tags.contains(&kind) + } +} + +/// Marker component: this NPC is scope-pinned — the eviction system must skip it. +/// +/// Kept in sync with `ScopeTag` by `sync_scope_pins`. Always use `ScopeTag` +/// as the source of truth; treat `ScopePinned` as a query-optimisation cache. +#[derive(Component, Debug, Clone, Copy, Default)] +pub struct ScopePinned; + /// Plugin registering the tier marker components and the tier transition system. pub struct TierPlugin; impl Plugin for TierPlugin { fn build(&self, app: &mut App) { + app.init_resource::(); + // Tier transition runs after movement so positions are current. app.add_systems( Update, update_tier_markers.after(crate::simulation::movement::validate_movement), ); + // Scope tag assignment runs each tick to keep KnownContact / Colleague current. + // Must run before sync_scope_pins so pins are correct before eviction checks. + // Eviction runs after scope pins are synced (respects ScopePinned). + // LastInteractionTick update runs after visibility geometry. + app.add_systems( + Update, + ( + assign_scope_tags, + sync_scope_pins.after(assign_scope_tags), + update_last_interaction_tick + .after(crate::perception::observer::compute_visibility_geometry), + evict_excess_active + .after(sync_scope_pins) + .after(update_tier_markers), + ), + ); tracing::debug!("TierPlugin initialized"); } } +// --------------------------------------------------------------------------- +// Scope tag systems (D-026, #98) +// --------------------------------------------------------------------------- + +/// System: assign `KnownContact` and `Colleague` scope tags from player epistemics. +/// +/// Runs each tick. Clears and recomputes `KnownContact` and `Colleague` tags for all +/// NPCs based on: +/// - `KnownContact`: player `KnowledgeGraph` has an entry for this NPC with +/// confidence >= `KnowsOf`. +/// - `Colleague`: global `RelationshipGraph` has an edge from the player to this NPC +/// with kind `Friend` or `Colleague`. +/// +/// `Neighborhood` and `ActiveQuest` tags are NOT touched by this system: +/// - `Neighborhood` is set at session start and persists (future sprint). +/// - `ActiveQuest` is reserved for the quest system (future sprint). +/// +/// No-op when there is no `PlayerCharacter` entity. +pub fn assign_scope_tags( + player_query: Query<(&KnowledgeGraph, &StableEntityId), With>, + rel_graph: Res, + mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With>, + mut commands: Commands, +) { + let Ok((player_kg, player_stable)) = player_query.single() else { + return; + }; + let player_id = player_stable.0; + + // Collect KnownContact set: entities in player KG with confidence >= KnowsOf. + // BTreeSet for deterministic iteration (D-010). + let known_contacts: BTreeSet<_> = player_kg + .entities + .iter() + .filter(|(_, ek)| ek.confidence >= KnowledgeConfidence::KnowsOf) + .map(|(id, _)| *id) + .collect(); + + // Collect Colleague set: player → NPC relationship edges with Friend/Colleague kind. + let colleagues: BTreeSet<_> = rel_graph + .relationships_of(&player_id) + .into_iter() + .filter(|(_, edge)| { + matches!(edge.kind, RelationshipKind::Friend | RelationshipKind::Colleague) + }) + .map(|(target_id, _)| *target_id) + .collect(); + + for (entity, npc_stable, maybe_scope_tag) in &mut npcs { + let npc_id = npc_stable.0; + let is_known = known_contacts.contains(&npc_id); + let is_colleague = colleagues.contains(&npc_id); + + match maybe_scope_tag { + Some(mut scope_tag) => { + // Remove computed tags, then re-add if still applicable. + scope_tag.remove(ScopeTagKind::KnownContact); + scope_tag.remove(ScopeTagKind::Colleague); + if is_known { + scope_tag.add(ScopeTagKind::KnownContact); + } + if is_colleague { + scope_tag.add(ScopeTagKind::Colleague); + } + } + None if is_known || is_colleague => { + // Create a new ScopeTag component for this NPC. + let mut scope_tag = ScopeTag::default(); + if is_known { + scope_tag.add(ScopeTagKind::KnownContact); + } + if is_colleague { + scope_tag.add(ScopeTagKind::Colleague); + } + commands.entity(entity).insert(scope_tag); + } + None => {} // NPC not known or related — no scope tag needed. + } + } +} + +/// System: keep `ScopePinned` markers in sync with `ScopeTag` components. +/// +/// Runs after `assign_scope_tags`. For each NPC: +/// - `ScopeTag` present and non-empty → add `ScopePinned` (if not already present). +/// - `ScopeTag` absent or empty → remove `ScopePinned` (if present). +/// +/// The eviction system (#97) queries `Without` to skip pinned NPCs. +pub fn sync_scope_pins( + mut commands: Commands, + needs_pin: Query<(Entity, &ScopeTag), Without>, + may_need_unpin: Query<(Entity, Option<&ScopeTag>), With>, +) { + // Add ScopePinned to NPCs that have a non-empty ScopeTag. + for (entity, scope_tag) in &needs_pin { + if scope_tag.is_pinned() { + commands.entity(entity).insert(ScopePinned); + } + } + + // Remove ScopePinned from NPCs whose ScopeTag is absent or empty. + for (entity, maybe_scope_tag) in &may_need_unpin { + let still_pinned = maybe_scope_tag.map(|s| s.is_pinned()).unwrap_or(false); + if !still_pinned { + commands.entity(entity).remove::(); + } + } +} + +// --------------------------------------------------------------------------- +// Eviction systems (D-026, #97) +// --------------------------------------------------------------------------- + +/// System: update `LastInteractionTick` for NPCs visible to the player (#97). +/// +/// Runs after visibility geometry is computed. Any NPC at a visible position +/// (in the player's LOS) gets its `LastInteractionTick` set to the current tick. +/// NPCs without this component get it inserted on first observation. +pub fn update_last_interaction_tick( + time: Res, + vis_geo: Res, + mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With>, + npcs_without_tick: Query<(Entity, &TilePosition), (With, Without)>, + mut commands: Commands, +) { + let current_tick = time.tick; + + // Update existing LastInteractionTick for visible NPCs. + for (pos, mut last_tick) in &mut npcs_with_tick { + if pos.z == vis_geo.observer_z + && vis_geo.visible_positions.contains(&(pos.x, pos.y)) + { + last_tick.0 = current_tick; + } + } + + // Insert LastInteractionTick for NPCs that don't have it yet but are visible. + for (entity, pos) in &npcs_without_tick { + if pos.z == vis_geo.observer_z + && vis_geo.visible_positions.contains(&(pos.x, pos.y)) + { + commands.entity(entity).insert(LastInteractionTick(current_tick)); + } + } +} + +/// System: evict excess `ActiveSim` entities when count exceeds capacity (#97). +/// +/// When more than `ACTIVE_SIM_CAPACITY` entities are in `ActiveSim`: +/// 1. Skip all `ScopePinned` entities (they stay Active regardless). +/// 2. Sort remaining by `LastInteractionTick` (oldest first) via min-heap. +/// 3. Demote the oldest N entities to `BackgroundSim` (or `StateSaved` if beyond +/// background radius). +/// +/// Updates `SimSpacePressure` resource with current counts. +pub fn evict_excess_active( + mut commands: Commands, + player_query: Query<&TilePosition, With>, + active_npcs: Query< + (Entity, &TilePosition, Option<&LastInteractionTick>), + (With, With, Without), + >, + active_count_query: Query<(), With>, + mut pressure: ResMut, +) { + let total_active = active_count_query.iter().count(); + pressure.active_count = total_active; + + if total_active <= pressure.capacity { + return; + } + + let excess = total_active - pressure.capacity; + let Ok(player_pos) = player_query.single() else { + return; + }; + + // Min-heap keyed by LastInteractionTick (oldest = smallest = evicted first). + // Entities without LastInteractionTick get tick 0 (most stale). + // NOTE: Ties in tick value are broken by Entity index, which is non-deterministic + // across runs (bevy Entity allocation order). For v0.1 this is acceptable — + // deterministic replay (D-010 principle 4) replays inputs, not eviction order. + // If eviction order must be deterministic, key by (tick, StableId) instead. + let mut heap: BinaryHeap> = BinaryHeap::new(); + for (entity, pos, maybe_tick) in &active_npcs { + let tick = maybe_tick.map(|t| t.0).unwrap_or(0); + heap.push(Reverse((tick, entity, *pos))); + } + + let mut evicted = 0; + while evicted < excess { + let Some(Reverse((_, entity, pos))) = heap.pop() else { + break; + }; + + let dist = tile_distance(player_pos, &pos); + if dist > BACKGROUND_RADIUS { + commands.entity(entity).remove::().insert(StateSaved); + } else { + commands.entity(entity).remove::().insert(BackgroundSim); + } + evicted += 1; + } + + if evicted > 0 { + tracing::debug!( + "evicted {} excess ActiveSim entities (was {}, cap {})", + evicted, + total_active, + pressure.capacity, + ); + } +} + // --- Tier transition system (D-026, #99) --- /// Manhattan tile distance between two positions, returning `u32::MAX` for @@ -432,4 +789,541 @@ mod tests { assert!(world.get::(npc).is_none(), "demoted to Background"); assert!(world.get::(npc).is_some()); } + + // ----------------------------------------------------------------------- + // ScopeTag component tests (#98, D-026) + // ----------------------------------------------------------------------- + + #[test] + fn scope_tag_with_creates_single_kind() { + let tag = ScopeTag::with(ScopeTagKind::KnownContact); + assert!(tag.contains(ScopeTagKind::KnownContact)); + assert!(!tag.contains(ScopeTagKind::Colleague)); + assert!(tag.is_pinned()); + } + + #[test] + fn scope_tag_add_and_remove() { + let mut tag = ScopeTag::default(); + assert!(!tag.is_pinned(), "new ScopeTag is empty"); + + tag.add(ScopeTagKind::Colleague); + assert!(tag.is_pinned()); + assert!(tag.contains(ScopeTagKind::Colleague)); + + tag.add(ScopeTagKind::KnownContact); + assert!(tag.contains(ScopeTagKind::KnownContact)); + + tag.remove(ScopeTagKind::Colleague); + assert!(!tag.contains(ScopeTagKind::Colleague)); + assert!(tag.is_pinned(), "still pinned by KnownContact"); + + tag.remove(ScopeTagKind::KnownContact); + assert!(!tag.is_pinned(), "unpinned when all tags removed"); + } + + #[test] + fn scope_tag_multiple_kinds_coexist() { + let mut tag = ScopeTag::default(); + tag.add(ScopeTagKind::Neighborhood); + tag.add(ScopeTagKind::ActiveQuest); + tag.add(ScopeTagKind::Colleague); + tag.add(ScopeTagKind::KnownContact); + + assert_eq!(tag.tags.len(), 4, "all four kinds present"); + assert!(tag.is_pinned()); + } + + // ----------------------------------------------------------------------- + // sync_scope_pins system tests (#98) + // ----------------------------------------------------------------------- + + fn run_sync_scope_pins(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(sync_scope_pins); + schedule.run(world); + } + + #[test] + fn sync_scope_pins_adds_scope_pinned_for_non_empty_tag() { + let mut world = World::new(); + let npc = world + .spawn((Npc, ScopeTag::with(ScopeTagKind::KnownContact))) + .id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::(npc).is_some(), + "ScopePinned added for non-empty ScopeTag" + ); + } + + #[test] + fn sync_scope_pins_does_not_add_for_empty_tag() { + let mut world = World::new(); + let npc = world.spawn((Npc, ScopeTag::default())).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::(npc).is_none(), + "ScopePinned must NOT be added for empty ScopeTag" + ); + } + + #[test] + fn sync_scope_pins_removes_scope_pinned_when_tag_emptied() { + let mut world = World::new(); + // Start with ScopePinned already set but ScopeTag now empty. + let npc = world.spawn((Npc, ScopePinned, ScopeTag::default())).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::(npc).is_none(), + "ScopePinned removed when ScopeTag is empty" + ); + } + + #[test] + fn sync_scope_pins_removes_scope_pinned_when_tag_absent() { + let mut world = World::new(); + // NPC has ScopePinned but no ScopeTag component at all. + let npc = world.spawn((Npc, ScopePinned)).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::(npc).is_none(), + "ScopePinned removed when ScopeTag absent" + ); + } + + #[test] + fn sync_scope_pins_keeps_existing_scope_pinned() { + // An NPC that already has ScopePinned AND a non-empty ScopeTag should remain pinned. + let mut world = World::new(); + let npc = world + .spawn((Npc, ScopePinned, ScopeTag::with(ScopeTagKind::Colleague))) + .id(); + + run_sync_scope_pins(&mut world); + + // After sync, the NPC should still have ScopePinned (it was already there + // AND the scope tag is non-empty — so no change needed). + assert!( + world.get::(npc).is_some(), + "ScopePinned preserved for non-empty ScopeTag" + ); + } + + // ----------------------------------------------------------------------- + // assign_scope_tags system tests (#98) + // ----------------------------------------------------------------------- + + fn run_assign_scope_tags(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(assign_scope_tags); + schedule.run(world); + } + + #[test] + fn assign_scope_tags_no_op_without_player() { + let mut world = World::new(); + world.init_resource::(); + + // NPC exists but no PlayerCharacter + let npc = world.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1)))).id(); + + run_assign_scope_tags(&mut world); + + // No ScopeTag should be assigned — no player + assert!(world.get::(npc).is_none()); + } + + #[test] + fn assign_scope_tags_known_contact_from_player_kg() { + use crate::knowledge::types::StableId; + + let mut world = World::new(); + world.init_resource::(); + + let npc_stable = StableId(10); + let player_stable = StableId(1); + + // Set up player with a KnowledgeGraph that knows the NPC at KnowsOf level. + let mut player_kg = KnowledgeGraph::new(); + player_kg.observe_entity(npc_stable, make_pos(5, 5), 0); + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // Spawn the NPC + let npc = world + .spawn((Npc, make_pos(10, 0), StableEntityId(npc_stable))) + .id(); + + run_assign_scope_tags(&mut world); + + let scope_tag = world.get::(npc).expect("ScopeTag should be assigned"); + assert!( + scope_tag.contains(ScopeTagKind::KnownContact), + "NPC known at KnowsOf level should get KnownContact tag" + ); + } + + #[test] + fn assign_scope_tags_colleague_from_relationship_graph() { + use crate::knowledge::types::StableId; + use crate::npc::relationships::RelationshipEdge; + + let mut world = World::new(); + + let npc_stable = StableId(20); + let player_stable = StableId(1); + + // Player KG is empty — no KnownContact. + let player_kg = KnowledgeGraph::new(); + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // Set up RelationshipGraph with player → NPC as Friend. + let mut rel_graph = RelationshipGraph::new(); + rel_graph.set_relationship( + player_stable, + npc_stable, + RelationshipEdge { + kind: RelationshipKind::Friend, + trust: 5, + history: vec![], + last_interaction_tick: 0, + }, + ); + world.insert_resource(rel_graph); + + let npc = world + .spawn((Npc, make_pos(0, 5), StableEntityId(npc_stable))) + .id(); + + run_assign_scope_tags(&mut world); + + let scope_tag = world.get::(npc).expect("ScopeTag assigned for colleague"); + assert!( + scope_tag.contains(ScopeTagKind::Colleague), + "Friend relationship should grant Colleague scope tag" + ); + } + + #[test] + fn assign_scope_tags_does_not_affect_unknown_npcs() { + use crate::knowledge::types::StableId; + + let mut world = World::new(); + world.init_resource::(); + + let player_stable = StableId(1); + let player_kg = KnowledgeGraph::new(); // empty — knows nobody + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // NPC that the player doesn't know + let npc = world + .spawn((Npc, make_pos(10, 0), StableEntityId(StableId(99)))) + .id(); + + run_assign_scope_tags(&mut world); + + assert!( + world.get::(npc).is_none(), + "unknown NPC should not receive ScopeTag" + ); + } + + #[test] + fn scope_pinned_npc_in_query_without_scope_pinned_marker() { + // Verify that ScopePinned is a separate marker and Without + // correctly excludes pinned NPCs from eviction queries. + let mut world = World::new(); + let pinned = world.spawn((Npc, ScopePinned)).id(); + let unpinned = world.spawn(Npc).id(); + + let mut query = world.query_filtered::, Without)>(); + let unpinned_results: Vec = query.iter(&world).collect(); + + assert_eq!(unpinned_results.len(), 1, "only one unpinned NPC"); + assert_eq!(unpinned_results[0], unpinned); + assert!(!unpinned_results.contains(&pinned), "pinned NPC excluded from eviction query"); + } + + // ----------------------------------------------------------------------- + // Eviction system tests (#97, D-026) + // ----------------------------------------------------------------------- + + fn run_evict_excess_active(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(evict_excess_active); + schedule.run(world); + } + + #[test] + fn no_eviction_when_under_capacity() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 5, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // Spawn 3 active NPCs (under cap of 5) + let npc1 = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id(); + let npc2 = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id(); + let npc3 = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id(); + + run_evict_excess_active(&mut world); + + // All should remain Active + assert!(world.get::(npc1).is_some()); + assert!(world.get::(npc2).is_some()); + assert!(world.get::(npc3).is_some()); + } + + #[test] + fn evicts_oldest_when_over_capacity() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 2, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // 3 NPCs, cap=2 → must evict 1 (the oldest: tick 10) + let oldest = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id(); + let mid = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id(); + let newest = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::(oldest).is_none(), "oldest evicted"); + assert!(world.get::(oldest).is_some(), "oldest → Background"); + assert!(world.get::(mid).is_some(), "mid stays Active"); + assert!(world.get::(newest).is_some(), "newest stays Active"); + } + + #[test] + fn eviction_skips_scope_pinned() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // 2 NPCs, cap=1. The oldest is ScopePinned → skip it, evict the other. + let pinned = world.spawn(( + Npc, ActiveSim, ScopePinned, + ScopeTag::with(ScopeTagKind::KnownContact), + make_pos(5, 0), LastInteractionTick(5), + )).id(); + let unpinned = world.spawn(( + Npc, ActiveSim, + make_pos(6, 0), LastInteractionTick(20), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::(pinned).is_some(), "pinned NPC stays Active"); + assert!(world.get::(unpinned).is_none(), "unpinned NPC evicted"); + assert!(world.get::(unpinned).is_some()); + } + + #[test] + fn eviction_demotes_to_state_saved_if_beyond_background_radius() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // NPC at distance 200 (beyond BACKGROUND_RADIUS=120) → StateSaved + let far = world.spawn(( + Npc, ActiveSim, + make_pos(200, 0), LastInteractionTick(5), + )).id(); + // NPC at distance 5 (within ACTIVE_RADIUS) → stays + let near = world.spawn(( + Npc, ActiveSim, + make_pos(5, 0), LastInteractionTick(50), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::(far).is_none(), "far NPC evicted"); + assert!(world.get::(far).is_some(), "far NPC → StateSaved"); + assert!(world.get::(near).is_some(), "near NPC stays Active"); + } + + #[test] + fn eviction_handles_npcs_without_last_interaction_tick() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // NPC without LastInteractionTick defaults to tick 0 (most stale) + let no_tick = world.spawn((Npc, ActiveSim, make_pos(5, 0))).id(); + let with_tick = world.spawn(( + Npc, ActiveSim, + make_pos(6, 0), LastInteractionTick(100), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::(no_tick).is_none(), "no-tick NPC evicted first"); + assert!(world.get::(no_tick).is_some()); + assert!(world.get::(with_tick).is_some(), "with-tick NPC stays"); + } + + #[test] + fn sim_space_pressure_updated_after_eviction() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 2, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))); + world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))); + world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))); + + run_evict_excess_active(&mut world); + + let pressure = world.resource::(); + // active_count is set BEFORE eviction runs (it reads the pre-eviction count). + // The actual count changes via deferred commands, which apply after the system. + assert_eq!(pressure.active_count, 3, "pressure tracks pre-eviction count"); + } + + #[test] + fn scope_pinned_npcs_survive_eviction_at_scale() { + // Regression: evict_excess_active must never demote a ScopePinned NPC, + // even when many NPCs are over capacity (D-026, #97, #98). + // + // Setup: 85 Active NPCs (capacity = 80 → 5 must be evicted). + // - 10 are ScopePinned (must ALL remain ActiveSim after eviction). + // - 75 are unpinned (5 oldest are eviction targets; 70 survive). + // + // The Without query filter in evict_excess_active is the + // core invariant under test. This test fails immediately if that filter + // is removed or mis-applied. + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 80, + }); + + // Player at origin — all NPCs are within BACKGROUND_RADIUS. + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // Spawn 10 ScopePinned NPCs. Give them the oldest ticks so they would + // be prime eviction candidates if Without were absent. + let pinned: Vec = (0..10) + .map(|i| { + world + .spawn(( + Npc, + ActiveSim, + ScopePinned, + make_pos(5 + i, 0), + LastInteractionTick(i as u64), + )) + .id() + }) + .collect(); + + // Spawn 5 unpinned NPCs with old ticks — these are the actual eviction targets. + let unpinned_oldest: Vec = (0..5) + .map(|i| { + world + .spawn(( + Npc, + ActiveSim, + make_pos(20 + i, 0), + LastInteractionTick(i as u64), + )) + .id() + }) + .collect(); + + // Spawn 70 unpinned NPCs with newer ticks — these survive. + for i in 0..70i32 { + world.spawn(( + Npc, + ActiveSim, + make_pos(30 + i, 0), + LastInteractionTick(100 + i as u64), + )); + } + + // Total: 10 pinned + 5 oldest-unpinned + 70 newer-unpinned = 85 active. + // cap = 80 → exactly 5 must be evicted. + run_evict_excess_active(&mut world); + + // Core invariant: ALL pinned entities remain ActiveSim. + for (i, &entity) in pinned.iter().enumerate() { + assert!( + world.get::(entity).is_some(), + "ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)", + i + ); + assert!( + world.get::(entity).is_none(), + "ScopePinned NPC {} must NOT be demoted to BackgroundSim", + i + ); + assert!( + world.get::(entity).is_none(), + "ScopePinned NPC {} must NOT be demoted to StateSaved", + i + ); + } + + // Sanity: the 5 oldest unpinned were the ones evicted. + let evicted_count = unpinned_oldest + .iter() + .filter(|&&e| world.get::(e).is_none()) + .count(); + assert_eq!( + evicted_count, 5, + "exactly 5 unpinned NPCs (the oldest) should have been evicted to reach capacity" + ); + } + + // ----------------------------------------------------------------------- + // LastInteractionTick component tests (#97) + // ----------------------------------------------------------------------- + + #[test] + fn last_interaction_tick_defaults_to_zero() { + let tick = LastInteractionTick::default(); + assert_eq!(tick.0, 0); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ecb056bf8..c84a98273 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -66,12 +66,12 @@ 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, player_knowledge: None, + save_result: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3ea63a72e..f608c3ba7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -52,12 +52,12 @@ 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, player_knowledge: None, + save_result: None, }; bridge diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index b32a4f1ae..642906ad0 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -41,12 +41,12 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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, player_knowledge: None, + save_result: None, } } @@ -232,12 +232,12 @@ 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, player_knowledge: None, + save_result: None, }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 95752ac95..3b8bbad72 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -39,7 +39,6 @@ "z": 0 } ], - "examine_result": null, "follow_state": null, "game_time": { "day": 0, @@ -74,7 +73,7 @@ "scan_events": [], "sound_events": [], "tick": 8, - "version": 14, + "version": 15, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs new file mode 100644 index 000000000..1f6d11cb7 --- /dev/null +++ b/server/tests/information_boundaries.rs @@ -0,0 +1,314 @@ +//! Information boundary negative test suite (D-010, D-030, ticket #272). +//! +//! THE core asymmetric information claim: entity X cannot see what entity Y +//! knows, unless the observation system explicitly grants it. +//! +//! These are NEGATIVE tests — they assert that information does NOT cross +//! boundaries. Each test uses `assert!(x.is_none())` or equivalent absence +//! patterns, not just "test passed because nothing happened." +//! +//! ## Test layers (D-030) +//! +//! Layer 1 (pure unit, no ECS): +//! - `player_kg_has_no_passive_npc_leakage` — KG starts empty, stays empty +//! - `save_state_npc_kg_isolation` — per-NPC KG serialization isolation +//! - `snapshot_excludes_entities_outside_los` — FOV geometry excludes far tiles +//! +//! Layer 2 (minimal ECS world, no subprocess): +//! - `background_npc_kg_not_updated_by_active_tier_events` — tier boundary holds +//! +//! Spec references: D-010 (info boundaries), D-026 (tiers), D-030 (testability), +//! D-041 (knowledge graph), Q-029 (save format) + +use bevy_ecs::prelude::*; +use bevy_ecs::schedule::Schedule; + +use settled_reach_server::knowledge::events::{ + process_knowledge_events, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, +}; +use settled_reach_server::knowledge::{ + ContradictionDetectedQueue, EntityRegistry, KnowledgeGraph, +}; +use settled_reach_server::knowledge::types::StableId; +use settled_reach_server::npc::{Npc, SecretSeverity}; +use settled_reach_server::npc::relationships::RelationshipGraph; +use settled_reach_server::perception::query::{NaturalVision, PerceptionQuery}; +use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::save_state::{NpcSaveState, SaveStateV1, SAVE_FORMAT_VERSION}; +use settled_reach_server::simulation::tier::{ActiveSim, BackgroundSim}; +use settled_reach_server::simulation::time::TickRate; +use settled_reach_server::bridge::types::FacingDirection; + +// =========================================================================== +// Layer 1 — Pure unit: no ECS world, no subprocess +// =========================================================================== + +/// IB-1 (Layer 1): A fresh KnowledgeGraph contains no entries for any entity. +/// +/// Core claim: player knowledge is never passively populated. The KG starts +/// empty and can only be written by `observe_entity()`, `record_knowledge()`, +/// or knowledge events processed by `process_knowledge_events`. Simply +/// existing in the simulation world does not leak an NPC's existence into +/// the player's knowledge graph. +/// +/// Spec reference: D-010 principle 2 (information boundaries as first-class system) +#[test] +fn player_kg_has_no_passive_npc_leakage() { + let player_kg = KnowledgeGraph::new(); + let npc_id = StableId(42); + + // Negative assertion: a freshly created KG contains no entity references. + assert!( + player_kg.entities.get(&npc_id).is_none(), + "IB-1: fresh KnowledgeGraph must not contain any entity (passive leakage — D-010 principle 2)" + ); + assert!( + player_kg.is_empty(), + "IB-1: KnowledgeGraph::new() must be completely empty" + ); + + // Negative assertion: spawning a bare ECS entity doesn't populate a KG. + // The knowledge graph is a component, not a global shared resource. + let mut world = World::new(); + let player = world + .spawn(KnowledgeGraph::new()) + .id(); + + // Spawn an NPC in the same world — no observation system runs. + let _npc = world.spawn((Npc, TilePosition::new(50, 50, 0))).id(); + + // Player's KG must be empty regardless of NPCs existing nearby. + let kg = world.get::(player).unwrap(); + assert!( + kg.entities.get(&npc_id).is_none(), + "IB-1: spawning an NPC in the world must not passively populate the player's KG" + ); + assert!( + kg.is_empty(), + "IB-1: player KG must stay empty until an observation system explicitly populates it" + ); +} + +/// IB-2 (Layer 1): FOV geometry excludes positions beyond the vision range. +/// +/// The observer snapshot system (compute_observer_snapshot) includes entities +/// by testing whether their tile position is in `VisibilityGeometry.visible_positions`. +/// This test verifies that the FOV computation — the upstream source of that set — +/// correctly excludes positions far from the observer, so no entity outside LOS +/// can ever appear in the snapshot. +/// +/// Spec reference: D-010 principle 2, D-011 (symmetric shadowcasting), D-030 Layer 1 +#[test] +fn snapshot_excludes_entities_outside_los() { + // All-walkable 100×100 map at z=0 — no walls to cast shadows. + let walkability = WalkabilityMap::new(100, 100, 1); + let observer_pos = TilePosition::new(5, 5, 0); + let facing = FacingDirection::North; + + let geometry = NaturalVision.compute_geometry(&observer_pos, facing, &walkability); + + // --- Far entity: 45 tiles away, well outside FOV range (~12 tiles) --- + let far_npc_pos = TilePosition::new(50, 5, 0); + assert!( + !geometry.visible_positions.contains(&(far_npc_pos.x, far_npc_pos.y)), + "IB-2: entity at {:?} (45 tiles from observer) must NOT be in FOV — \ + observer snapshot would exclude this entity (fog of perception, D-010 principle 2)", + far_npc_pos + ); + + // --- Sanity check: the observer's own position is visible --- + assert!( + geometry.visible_positions.contains(&(observer_pos.x, observer_pos.y)), + "IB-2 sanity: observer's own position must always be in the FOV set" + ); + + // --- Additional sanity: an immediately adjacent tile (1 step) is visible --- + let adjacent_pos = TilePosition::new(6, 5, 0); + assert!( + geometry.visible_positions.contains(&(adjacent_pos.x, adjacent_pos.y)), + "IB-2 sanity: tile immediately adjacent to observer must be visible" + ); +} + +/// IB-4 (Layer 1): NPC save states do not bleed each other's KnowledgeGraphs. +/// +/// `SaveStateV1.npc_states` is a flat `Vec`. Each `NpcSaveState` +/// has its own optional `knowledge_graph: Option`. After a +/// serialise → deserialise roundtrip: +/// - NPC_A's `NpcSaveState.knowledge_graph` contains ONLY NPC_A's own KG. +/// - NPC_B's `NpcSaveState.knowledge_graph` is `None` (Background tier, +/// no KG carried) — it must not be overwritten by NPC_A's KG data. +/// +/// Spec reference: D-010 principle 2, D-026 (tier serialization), Q-029 (save format) +#[test] +fn save_state_npc_kg_isolation() { + let npc_a_id = StableId(1); + let npc_b_id = StableId(2); + + // NPC_A (Active tier) carries a KG that has observed NPC_B. + let mut npc_a_kg = KnowledgeGraph::new(); + // NPC_A has observed NPC_B at some position — this puts NPC_B in NPC_A's KG. + let _ = npc_a_kg.observe_entity(npc_b_id, TilePosition::new(10, 10, 0), 5); + + let npc_a_state = NpcSaveState { + stable_id: npc_a_id, + position: TilePosition::new(5, 5, 0), + secret_severity: SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 20, + contentment: 50, + knowledge_graph: Some(npc_a_kg), // Active NPC carries KG + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + }; + + // NPC_B (Background tier) does not carry a KG. + let npc_b_state = NpcSaveState { + stable_id: npc_b_id, + position: TilePosition::new(20, 20, 0), + secret_severity: SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 20, + contentment: 50, + knowledge_graph: None, // Background NPC carries no KG + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + }; + + let save = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 10, + tick_rate: TickRate::Full, + seed: 42, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![npc_a_state, npc_b_state], + }; + + // Roundtrip: serialize → deserialize. + let bytes = save.to_bytes().expect("IB-4: serialize SaveStateV1"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("IB-4: deserialize SaveStateV1"); + + // --- Negative assertion: NPC_B's state must NOT contain a KnowledgeGraph --- + let npc_b_recovered = recovered + .npc_states + .iter() + .find(|s| s.stable_id == npc_b_id) + .expect("IB-4: NPC_B must be present in recovered npc_states"); + + assert!( + npc_b_recovered.knowledge_graph.is_none(), + "IB-4: NPC_B's recovered state must not contain a KnowledgeGraph — \ + serialization must not bleed NPC_A's KG data into NPC_B's entry (D-010 principle 2)" + ); + + // --- Sanity: NPC_A's state must contain its own KG (not lost in roundtrip) --- + let npc_a_recovered = recovered + .npc_states + .iter() + .find(|s| s.stable_id == npc_a_id) + .expect("IB-4: NPC_A must be present in recovered npc_states"); + + let kg = npc_a_recovered + .knowledge_graph + .as_ref() + .expect("IB-4: NPC_A's KG must survive roundtrip"); + + // NPC_A's KG entry for NPC_B is NPC_A's OBSERVATION DATA (where NPC_A saw NPC_B). + // This is not NPC_B's own KG — it's NPC_A's record of NPC_B's position. + assert!( + kg.entities.get(&npc_b_id).is_some(), + "IB-4 sanity: NPC_A's KG should still contain its observation of NPC_B after roundtrip" + ); +} + +// =========================================================================== +// Layer 2 — Minimal ECS world (no subprocess) +// =========================================================================== + +/// IB-3 (Layer 2): `process_knowledge_events` only modifies the observer entity. +/// +/// Background-tier NPC KnowledgeGraphs must not be modified when Active-tier +/// events are processed. The `process_knowledge_events` system routes events +/// via `event.observer` (an ECS Entity handle) — only the targeted entity's KG +/// is written. This test confirms that a Background-tier NPC, not named in any +/// event's `observer` field, has its KG left completely unchanged. +/// +/// Spec reference: D-010 principle 2, D-026 (tier boundary), D-030 Layer 2 +#[test] +fn background_npc_kg_not_updated_by_active_tier_events() { + let mut world = World::new(); + + // Required resources for process_knowledge_events. + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + // Active-tier NPC: will be the observer in the knowledge event. + let active_npc = world + .spawn((Npc, ActiveSim, KnowledgeGraph::new())) + .id(); + + // Background-tier NPC: must NOT be affected. + let background_npc = world + .spawn((Npc, BackgroundSim, KnowledgeGraph::new())) + .id(); + + // A separate "observed" entity (the target of the DirectObservation). + // Register it in the EntityRegistry so process_knowledge_events can resolve its StableId. + let observed_entity = world.spawn_empty().id(); + { + let mut registry = world.resource_mut::(); + registry.register(observed_entity); + } + + // Push a DirectObservation event targeting only the Active NPC as observer. + // The Background NPC is not mentioned anywhere in this event. + world + .resource_mut::() + .push(KnowledgeEvent { + observer: active_npc, + tick: 1, + event_type: KnowledgeEventType::DirectObservation { + target: observed_entity, + position: TilePosition::new(5, 5, 0), + }, + }); + + // Run the knowledge event processing system. + let mut schedule = Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + // --- Negative assertion: Background NPC's KG must be completely unchanged --- + let bg_kg = world + .get::(background_npc) + .expect("IB-3: BackgroundSim NPC must still have KnowledgeGraph component"); + + assert!( + bg_kg.is_empty(), + "IB-3: Background-tier NPC KG must not be modified by Active-tier events. \ + process_knowledge_events must only update the event.observer entity (D-026 tier boundary, \ + D-010 principle 2). Found {} entity entries and {} fact entries.", + bg_kg.entity_count(), + bg_kg.fact_count() + ); +} diff --git a/server/tests/integration/mod.rs b/server/tests/integration/mod.rs new file mode 100644 index 000000000..bf630cd42 --- /dev/null +++ b/server/tests/integration/mod.rs @@ -0,0 +1,60 @@ +//! Layer 3 integration test entry point (D-030, ticket #200). +//! +//! ## Three-layer test architecture (D-030 sub-decision 3) +//! +//! ```text +//! Layer 1 — Fixture-based serialization (FAST, run on every edit) +//! Scope: Pure unit tests. No ECS world. No subprocess. +//! Tools: Rust #[test] + data structures directly. +//! Speed: <1ms each. +//! Files: tests/serialization.rs, tests/information_boundaries.rs (Layer 1 tests), +//! #[cfg(test)] mod tests within src/ modules +//! +//! Layer 2 — Mock subprocess / minimal ECS world (MEDIUM, run on every PR) +//! Scope: Minimal bevy App or World. Real systems, no real subprocess. +//! IPC roundtrip over Unix socket without spawning the binary. +//! Tools: bevy_ecs World + Schedule, or LocalBridge with in-process simulation. +//! Speed: 1ms–100ms each. +//! Files: tests/bridge_ipc.rs, tests/bridge_tcp.rs, +//! tests/information_boundaries.rs (Layer 2 tests), +//! tests/determinism.rs, tests/movement.rs, tests/smoke.rs +//! +//! Layer 3 — Real subprocess integration (SLOW, run daily / pre-merge) +//! Scope: Full binary spawned as a child process. No mocks. Real IPC. +//! Exercises the complete path: spawn → handshake → tick → snapshot. +//! Tools: std::process::Command, TcpStream. +//! Speed: 1s–15s each (process startup dominates). +//! Files: tests/layer3.rs, tests/integration/ (this module) +//! ``` +//! +//! ## Layer 3 test guidelines +//! +//! - Always set a deadline for server startup (`LISTEN_TIMEOUT`). +//! - Always kill the child process in teardown (even on test failure — use a +//! RAII guard or drop the handle at end of test). +//! - Use `--port 0` to get a kernel-assigned port; parse `LISTENING:{port}` from +//! stdout to obtain the actual port. +//! - Serialize `PlayerInput` via `rmp_serde`, frame with `bridge::framing::write_framed`. +//! - Deserialize `ObserverSnapshot` via `rmp_serde` after `bridge::framing::read_framed`. +//! +//! Spec reference: D-030 (testability architecture), D-020 (subprocess IPC protocol) + +// --------------------------------------------------------------------------- +// Stub: Layer 3 startup smoke test +// --------------------------------------------------------------------------- + +/// Placeholder for future Layer 3 tests that require full subprocess setup. +/// +/// Non-blocking tests that exercise the simulation binary end-to-end live in +/// `tests/layer3.rs`. This module is the organisational entry point for tests +/// that exercise multi-message Layer 3 scenarios (multi-tick sequences, +/// save/load roundtrip over IPC, protocol version negotiation). +/// +/// See `tests/layer3.rs::server_subprocess_sends_snapshot_on_connect` for the +/// canonical Layer 3 pattern. +#[test] +fn layer3_module_entry_point_placeholder() { + // This test exists to verify the integration module compiles and is + // discovered by cargo test. Real Layer 3 scenario tests replace this. + // D-030 Layer 3 stubs are acceptable until the IPC handshake (#555) lands. +} diff --git a/server/tests/layer3.rs b/server/tests/layer3.rs index 07cc6eaf1..fc8524a1b 100644 --- a/server/tests/layer3.rs +++ b/server/tests/layer3.rs @@ -72,7 +72,19 @@ fn server_subprocess_sends_snapshot_on_connect() { let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader")); let mut writer = BufWriter::new(stream); - // 4. Send one PlayerInput (idle tick 0) + // 4. Read the protocol handshake (first framed message, #555) + let handshake_frame = read_framed(&mut reader) + .expect("read handshake frame") + .expect("server closed connection before sending handshake"); + let handshake: HandshakeMessage = + rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage"); + assert_eq!( + handshake.protocol_version, PROTOCOL_VERSION, + "handshake protocol_version mismatch: got {}, expected {}", + handshake.protocol_version, PROTOCOL_VERSION + ); + + // 5. Send one PlayerInput (idle tick 0) let inputs = vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth, @@ -80,14 +92,14 @@ fn server_subprocess_sends_snapshot_on_connect() { let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput"); write_framed(&mut writer, &payload).expect("send PlayerInput to server"); - // 5. Read one ObserverSnapshot + // 6. Read one ObserverSnapshot let response = read_framed(&mut reader) .expect("read snapshot frame") .expect("server closed connection before sending snapshot"); let snapshot: ObserverSnapshot = rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); - // 6. Assert protocol correctness (D-020) + // 7. Assert protocol correctness (D-020) assert_eq!( snapshot.version, PROTOCOL_VERSION, "protocol version mismatch: got {}, expected {}", @@ -105,7 +117,7 @@ fn server_subprocess_sends_snapshot_on_connect() { .any(|e| matches!(e.kind, EntityKind::Player)); assert!(has_player, "snapshot must contain a Player entity"); - // 7. Clean up: drop connection so the server exits its game loop + // 8. Clean up: drop connection so the server exits its game loop drop(reader); drop(writer); diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 91d0c6bf4..8a505e69b 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -30,12 +30,12 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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, player_knowledge: None, + save_result: None, } } @@ -120,6 +120,12 @@ fn all_player_action_variants_roundtrip() { target_entity_id: 42, response_id: "kael-davan_d_001".to_string(), }, + PlayerAction::SaveGame { + path: "/tmp/test.msgpack".to_string(), + }, + PlayerAction::LoadGame { + path: "/tmp/test.msgpack".to_string(), + }, ]; for action in actions { @@ -280,12 +286,12 @@ 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, player_knowledge: None, + save_result: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -340,7 +346,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 14, + PROTOCOL_VERSION, 15, "bump this assertion when protocol version changes" ); } @@ -384,12 +390,12 @@ 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, player_knowledge: None, + save_result: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -1433,8 +1439,8 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { let decoded: ObserverSnapshot = serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); - // Version matches what was in the wire (13, simulating older server) - assert_eq!(decoded.version, 13); + // Version matches what was in the wire + assert_eq!(decoded.version, 14); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1);