Compare commits
44
Commits
@@ -46,6 +46,14 @@
|
||||
"Bash(tea *)",
|
||||
"Bash(tooling/tea-comment *)",
|
||||
|
||||
"Bash(cargo test *)",
|
||||
"Bash(cargo test)",
|
||||
"Bash(cargo build *)",
|
||||
"Bash(cargo build)",
|
||||
"Bash(cargo check *)",
|
||||
"Bash(cargo check)",
|
||||
"Bash(tests/run-*)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(find *)",
|
||||
|
||||
@@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Branch guard — MUST be on `main`
|
||||
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||
"PR reviews must be run from the `main` worktree. Switch to `main` first."
|
||||
Do NOT proceed with the review from a team branch.
|
||||
"PR reviews must be run by a Claude instance in the `main` worktree."
|
||||
Do NOT proceed with the review. Do NOT work around this by reading files
|
||||
from another worktree — the review agent itself must be running in main.
|
||||
Stop and wait for the user to invoke `/pr-review` from main.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
|
||||
@@ -288,24 +288,63 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead. No exceptions.
|
||||
|
||||
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
|
||||
Before creating a new file, Glob for similar files to learn
|
||||
the existing patterns (naming, structure, imports). Follow
|
||||
the conventions you find — do not invent new ones.
|
||||
|
||||
4. VERIFY AFTER WRITE: After implementing a change, grep for
|
||||
all references to functions/properties/classes you modified
|
||||
or removed. If you renamed, moved, or deleted something,
|
||||
update EVERY call site. Missing a call site breaks tests
|
||||
and blocks the team.
|
||||
|
||||
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
|
||||
parts of the ticket are implemented. If the ticket says
|
||||
'deliver A, B, and C', all three must exist and work. If
|
||||
you cannot complete part of a task, message the team lead
|
||||
explaining what is blocked and what remains — do NOT mark
|
||||
it completed.
|
||||
|
||||
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
|
||||
in 3 attempts, stop and message the team lead immediately.
|
||||
Do not silently skip work or leave stubs. Do not move to
|
||||
the next task while the current one is incomplete.
|
||||
|
||||
7. BACKWARD COMPATIBILITY: When extracting, moving, or
|
||||
refactoring code, ensure all existing consumers still work.
|
||||
Add proxy methods/properties if needed. Grep for the old
|
||||
name to find every call site.
|
||||
|
||||
WORKFLOW:
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
5. When done, mark the task completed and check TaskList for
|
||||
the next available task.
|
||||
5. Before marking done, verify:
|
||||
- All deliverables from the ticket exist (not just some)
|
||||
- No broken references (grep for changed names/signatures)
|
||||
- New files follow existing naming and directory conventions
|
||||
- Modified files still parse (no syntax errors)
|
||||
6. Mark the task completed and check TaskList for the next
|
||||
available task.
|
||||
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||
on your own.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
|
||||
@@ -21,6 +21,7 @@ run_check() {
|
||||
|
||||
# --- Checks ---
|
||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
|
||||
@@ -43,3 +43,4 @@ Thumbs.db
|
||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||
.claude/plans/
|
||||
.claude/projects/
|
||||
.claude/agent-memory/
|
||||
|
||||
+41
-1
@@ -6,7 +6,42 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.19] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||
- CI PR #69 merged — Sprint 19 test runners, IPC fixtures, protocol handshake, benchmark (4 tickets, 1297 lines)
|
||||
- Test runner scripts — 7 bash scripts (run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-ipc-benchmark, run-all) with structured JSON output (#270, D-030)
|
||||
- IPC serialization fixtures — 5 msgpack fixtures with Rust generator, cross-language GDScript validation (22 assertions) (#271, D-030)
|
||||
- Protocol handshake client — HANDSHAKING state in SimBridge, HandshakeMessage decode with 5s timeout (#556, D-020)
|
||||
- IPC round-trip benchmark — p50/p95/p99 latency reporting, 5ms threshold (#342, D-020)
|
||||
- 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)
|
||||
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
|
||||
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
|
||||
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
|
||||
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
|
||||
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
|
||||
- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API
|
||||
- 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/<game-id>/`, 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)
|
||||
@@ -40,7 +75,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- D-084: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||
@@ -55,6 +90,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||
|
||||
### Fixed
|
||||
- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field)
|
||||
- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant
|
||||
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||
|
||||
### Changed
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
|
||||
@@ -56,12 +56,14 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
|
||||
@@ -7,7 +7,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -23,11 +24,15 @@ help:
|
||||
@echo " make stop Stop any running server instance"
|
||||
@echo " make client Run the Godot client (test mode)"
|
||||
@echo " make server Run the Rust simulation server"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
|
||||
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
||||
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
|
||||
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make check-protocol Verify server/client protocol versions match"
|
||||
@echo " make clean Remove build artifacts and caches"
|
||||
@echo ""
|
||||
@@ -128,7 +133,7 @@ stop:
|
||||
test: test-server test-client
|
||||
|
||||
test-server:
|
||||
cd server && cargo nextest run
|
||||
tests/run-rust
|
||||
|
||||
fixtures:
|
||||
cd server && cargo test --test gen_fixtures -- --ignored
|
||||
@@ -169,8 +174,19 @@ golden-update:
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
test-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
tests/run-godot
|
||||
|
||||
test-ipc-fixtures:
|
||||
tests/run-ipc-fixtures
|
||||
|
||||
test-ipc-protocol:
|
||||
tests/run-ipc-protocol
|
||||
|
||||
test-ipc-integration:
|
||||
tests/run-ipc-integration
|
||||
|
||||
test-ipc-benchmark:
|
||||
tests/run-ipc-benchmark
|
||||
|
||||
# --- Lint ---
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -170,6 +173,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
|
||||
+12
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -135,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]
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||
|
||||
; Main menu — New Game / Continue / Quit.
|
||||
; #258: D-085 per-game save directory created on New Game.
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_mainmenu")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -120.0
|
||||
offset_top = -80.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "THE SETTLED REACH"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 36
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
|
||||
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "NEW GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
@@ -1,9 +1,19 @@
|
||||
extends Node
|
||||
|
||||
signal game_id_changed(new_id: String)
|
||||
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
|
||||
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||
# Empty string when no session is active (main menu state).
|
||||
var current_game_id: String = "":
|
||||
set(v):
|
||||
current_game_id = v
|
||||
game_id_changed.emit(v)
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
@@ -60,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}]
|
||||
|
||||
@@ -269,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
extends Node
|
||||
## D-085 (#258): Game session lifecycle manager.
|
||||
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||
## and handles quit-to-menu flow with save confirmation.
|
||||
##
|
||||
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||
|
||||
const SAVES_DIR := "user://saves/"
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||
|
||||
var _quit_dialog: ConfirmationDialog = null
|
||||
|
||||
|
||||
## Generate a new game-id, create its save directory, and activate the session.
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||
func list_game_dirs() -> Array:
|
||||
var dir := DirAccess.open(SAVES_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
var results: Array = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not entry.begins_with("."):
|
||||
var dir_path := SAVES_DIR + entry + "/"
|
||||
var newest_save := _find_newest_save(dir_path)
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
return results
|
||||
|
||||
|
||||
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||
func quit_to_menu() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
return # Dialog already open
|
||||
_quit_dialog = ConfirmationDialog.new()
|
||||
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||
get_tree().root.add_child(_quit_dialog)
|
||||
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||
_quit_dialog.popup_centered()
|
||||
|
||||
|
||||
func _do_quit_to_menu() -> void:
|
||||
_cleanup_quit_dialog()
|
||||
# #554: 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)
|
||||
|
||||
|
||||
func _cleanup_quit_dialog() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
_quit_dialog.queue_free()
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return ""
|
||||
var best_name := ""
|
||||
var best_time: int = 0
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||
if mtime > best_time:
|
||||
best_time = mtime
|
||||
best_name = entry
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return best_name
|
||||
@@ -1,17 +1,11 @@
|
||||
extends Node
|
||||
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
var _test_tick: int = 0
|
||||
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
# Signals
|
||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||
signal snapshot_received(snapshot: Dictionary)
|
||||
signal handshake_complete(protocol_version: int)
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = TestHarness.new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
# Reset test state — call before tests that use _test_snapshot()
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
func reset_test_state() -> void:
|
||||
_test_tick = 0
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
if harness: harness.reset()
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
set(v):
|
||||
if harness: harness.tick = v
|
||||
|
||||
var _test_player_pos: Vector2i:
|
||||
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||
set(v):
|
||||
if harness: harness.player_pos = v
|
||||
|
||||
var _test_facing: String:
|
||||
get: return harness.facing if harness else "North"
|
||||
set(v):
|
||||
if harness: harness.facing = v
|
||||
|
||||
var _test_in_dialogue: bool:
|
||||
get: return harness.in_dialogue if harness else false
|
||||
set(v):
|
||||
if harness: harness.in_dialogue = v
|
||||
|
||||
var _test_gauntlet_mode: bool:
|
||||
get: return harness.gauntlet_mode if harness else false
|
||||
set(v):
|
||||
if harness: harness.gauntlet_mode = v
|
||||
|
||||
var _test_npc_relationship: String:
|
||||
get: return harness.npc_relationship if harness else "Unknown"
|
||||
set(v):
|
||||
if harness: harness.npc_relationship = v
|
||||
|
||||
var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
|
||||
_bridge.poll()
|
||||
match _bridge.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
_handshake_start_usec = Time.get_ticks_usec()
|
||||
_set_state(ConnectionState.HANDSHAKING)
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Still connecting, wait
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -134,6 +179,62 @@ func _process(delta: float) -> void:
|
||||
_bridge = null # Reset and retry
|
||||
return
|
||||
|
||||
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
|
||||
if state == ConnectionState.HANDSHAKING:
|
||||
if _bridge == null:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
_bridge.poll()
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||
var reason := "Connection dropped during handshake"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge = null
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Check timeout
|
||||
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
|
||||
var reason := "Handshake timeout: no message received within 5 seconds"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Try to read first message
|
||||
var msg := _bridge.poll_message()
|
||||
if msg.is_empty():
|
||||
return # Not ready yet, continue polling
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||
or not decoded.value.has("protocol_version"):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||
server_version, Protocol.PROTOCOL_VERSION]
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
return
|
||||
|
||||
if _bridge == null:
|
||||
return
|
||||
|
||||
@@ -170,9 +271,13 @@ func _process(delta: float) -> void:
|
||||
push_warning("SimBridge: connection lost")
|
||||
_set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
# -- Input / snapshot ----------------------------------------------------------
|
||||
|
||||
# Send input to simulation server.
|
||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
|
||||
# In test mode, inputs are delegated to the test harness.
|
||||
# In live mode, encoded and buffered for transport.
|
||||
# Returns OK on success, or an error code on failure.
|
||||
func send_input(player_input: Dictionary) -> Error:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
@@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
harness.process_facing(facing)
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
harness.process_input(wire_name)
|
||||
return OK
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
@@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return OK
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
return null
|
||||
|
||||
if test_mode:
|
||||
var snapshot = _test_snapshot()
|
||||
var snapshot = harness.snapshot()
|
||||
snapshot_received.emit(snapshot)
|
||||
return snapshot
|
||||
|
||||
@@ -252,6 +352,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.
|
||||
@@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
_outbound_buffer.clear()
|
||||
return inputs
|
||||
|
||||
|
||||
# -- Wire protocol mapping -----------------------------------------------------
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
@@ -286,319 +392,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 ""
|
||||
|
||||
# Dynamic test snapshot — processes queued inputs to move player, generates
|
||||
# visibility based on current position. Matches Protocol.decode_snapshot() format.
|
||||
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
|
||||
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
|
||||
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
|
||||
# map layout and can exercise the rendering pipeline independently.
|
||||
func _test_snapshot() -> Dictionary:
|
||||
_test_tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
|
||||
_test_in_dialogue = true
|
||||
continue
|
||||
var delta := _action_to_delta(action_name)
|
||||
var new_pos := _test_player_pos + delta
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
_test_input_queue.clear()
|
||||
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if _test_tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
|
||||
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
|
||||
# Movement (walk-away) clears it. Client consume-once guards against re-show.
|
||||
# Options: structured {text, response_id, priority} per #435.
|
||||
var dialogue: Variant = null
|
||||
if _test_in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
|
||||
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
|
||||
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := _test_tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||
# Conversation ends after 6 exchanges (~30 ticks).
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif _test_tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": _test_tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": _test_facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _test_tiles(),
|
||||
"visible_tiles": _test_visible_tiles(),
|
||||
"visible_positions": _test_visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
func _test_tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
# Door on the south wall, center
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
# Corridor south of the door
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
# Test visible tiles with visibility sectors (v2 format)
|
||||
# Tiles ahead of the player are Forward, others Peripheral.
|
||||
func _test_visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
# Test visibility: tiles within radius 4 of player, inside room bounds
|
||||
func _test_visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Test mode helpers --
|
||||
|
||||
const _TEST_WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
func _test_is_walkable(pos: Vector2i) -> bool:
|
||||
return not _TEST_WALLS.has(pos)
|
||||
|
||||
# Simple LOS check — blocked if a wall tile sits between start and end
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
# Bresenham-lite: check tiles along the line
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
static func _action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
static func _delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 --------------------------------
|
||||
@@ -207,7 +207,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
var poi_list: Array = []
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
@@ -219,7 +219,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"category": str(raw_poi.get("category", "Location")),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
class_name TestHarness
|
||||
extends RefCounted
|
||||
## Standalone test simulation for client development without a running server.
|
||||
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
var input_queue: Array = []
|
||||
var in_dialogue: bool = false
|
||||
var gauntlet_mode: bool = false
|
||||
var npc_relationship: String = "Unknown"
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
tick = 0
|
||||
player_pos = Vector2i(10, 10)
|
||||
facing = "North"
|
||||
input_queue.clear()
|
||||
in_dialogue = false
|
||||
gauntlet_mode = false
|
||||
npc_relationship = "Unknown"
|
||||
|
||||
|
||||
func process_input(action_name: String) -> void:
|
||||
input_queue.append(action_name)
|
||||
|
||||
|
||||
func process_facing(new_facing: String) -> void:
|
||||
facing = new_facing
|
||||
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
player_pos = Vector2i(10, 10)
|
||||
in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and has_los(player_pos, npc_pos):
|
||||
in_dialogue = true
|
||||
continue
|
||||
var delta := action_to_delta(action_name)
|
||||
var new_pos := player_pos + delta
|
||||
if _is_walkable(new_pos):
|
||||
player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
if in_dialogue:
|
||||
in_dialogue = false
|
||||
input_queue.clear()
|
||||
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062)
|
||||
var dialogue: Variant = null
|
||||
if in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060)
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if tick >= conv_start and tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _tiles(),
|
||||
"visible_tiles": _visible_tiles(),
|
||||
"visible_positions": _visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
"save_result": null,
|
||||
}
|
||||
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
func _visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
|
||||
func _visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
|
||||
func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
@@ -1,5 +1,15 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
## #348: F3 debug overlay — real-time visualization of game state for dev use.
|
||||
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
|
||||
##
|
||||
## Panels:
|
||||
## 1. Stats text (top-left): tick, pos, fps, etc.
|
||||
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
|
||||
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
@@ -8,31 +18,150 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
const COL_GAP := 16
|
||||
|
||||
# World overlay colors
|
||||
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
|
||||
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
|
||||
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
|
||||
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
|
||||
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
|
||||
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
|
||||
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
|
||||
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
|
||||
const TAG_TEXT_COLOR := Color("#c8d0e0")
|
||||
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
|
||||
const GRAPH_LINE_COLOR := Color("#6bc9a6")
|
||||
const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
# NPC path history
|
||||
const NPC_HISTORY_LEN: int = 12
|
||||
const NPC_DOT_RADIUS: float = 3.5
|
||||
const PLAYER_DOT_RADIUS: float = 5.0
|
||||
|
||||
# Tick timing graph
|
||||
const GRAPH_W: float = 160.0
|
||||
const GRAPH_H: float = 48.0
|
||||
const GRAPH_MARGIN: float = 10.0
|
||||
const TICK_HISTORY_LEN: int = 30
|
||||
const TICK_WARN_MS: float = 120.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _cached_font: Font = null
|
||||
var _dev_mode: bool = false
|
||||
|
||||
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
|
||||
var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
# Clear NPC path history on session change to prevent entity ID collisions
|
||||
GameState.connect("game_id_changed", _on_game_id_changed)
|
||||
|
||||
|
||||
func _on_game_id_changed(_new_id: String) -> void:
|
||||
_npc_paths.clear()
|
||||
_tick_deltas.clear()
|
||||
_tick_times.clear()
|
||||
_last_tick_processed = -1
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _dev_mode:
|
||||
return
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
if not _dev_mode or not visible:
|
||||
return
|
||||
|
||||
# Record tick arrival time for timing graph
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if _last_tick_processed != GameState.current_tick:
|
||||
_last_tick_processed = GameState.current_tick
|
||||
if _tick_times.size() > 0:
|
||||
_tick_deltas.append(float(now_ms - _tick_times.back()))
|
||||
if _tick_deltas.size() > TICK_HISTORY_LEN:
|
||||
_tick_deltas.pop_front()
|
||||
_tick_times.append(now_ms)
|
||||
if _tick_times.size() > TICK_HISTORY_LEN + 1:
|
||||
_tick_times.pop_front()
|
||||
_update_npc_paths()
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_npc_paths() -> void:
|
||||
var seen_ids: Dictionary = {}
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
var kind_variant: String = entity.get("kind", {}).get("variant", "")
|
||||
if kind_variant != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if eid < 0:
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
if not _npc_paths.has(eid):
|
||||
_npc_paths[eid] = []
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() == 0 or path.back() != pos:
|
||||
path.append(pos)
|
||||
if path.size() > NPC_HISTORY_LEN:
|
||||
path.pop_front()
|
||||
# Prune entities no longer visible
|
||||
for eid in _npc_paths.keys():
|
||||
if not seen_ids.has(eid):
|
||||
_npc_paths.erase(eid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
_draw_stats_panel()
|
||||
_draw_world_overlays()
|
||||
_draw_tick_graph()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
@@ -71,7 +200,10 @@ func _draw() -> void:
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var gid := GameState.current_game_id
|
||||
left_lines.append(["game_id", gid if gid != "" else "-"])
|
||||
right_lines.append(["npc_paths", str(_npc_paths.size())])
|
||||
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
@@ -89,27 +221,240 @@ func _draw() -> void:
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
|
||||
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
|
||||
# Using this transform converts world coords → screen pixel coords for the overlays.
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_screen := _w2s(GameState.player_position, canvas_xf)
|
||||
|
||||
_draw_vision_cone(player_screen, canvas_xf)
|
||||
_draw_los_rays(player_screen, canvas_xf)
|
||||
_draw_npc_paths(canvas_xf)
|
||||
_draw_info_tags(canvas_xf)
|
||||
|
||||
|
||||
# Convert world tile position → screen pixel position
|
||||
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
|
||||
return canvas_xf * (world_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# Vision cone: filled forward sector + peripheral bands.
|
||||
# Uses player facing direction and visibility sector distance.
|
||||
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var facing_angle := _facing_to_angle(GameState.player_facing)
|
||||
|
||||
# Estimate visible radius from furthest visibility sector tile
|
||||
var max_d: float = 4.0
|
||||
for vpos in GameState.visibility_sectors.keys():
|
||||
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
|
||||
if d > max_d:
|
||||
max_d = d
|
||||
var scale_x := canvas_xf.x.length()
|
||||
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
|
||||
|
||||
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
|
||||
var forward_from := facing_angle - CONE_FORWARD_HALF
|
||||
var forward_to := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
|
||||
var perip_l_to := facing_angle - CONE_FORWARD_HALF
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
|
||||
# Player dot
|
||||
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
var t := float(i) / float(CONE_ARC_STEPS)
|
||||
var a := angle_from + t * (angle_to - angle_from)
|
||||
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
# Dashed LOS lines from player to each visible non-player entity
|
||||
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") == "Player":
|
||||
continue
|
||||
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
var entity_screen := _w2s(entity_world, canvas_xf)
|
||||
var rel: String = entity.get("relationship", "Unknown")
|
||||
var color := Constants.color_for_relationship(rel)
|
||||
color.a = 0.45
|
||||
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
|
||||
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
|
||||
|
||||
|
||||
# Fading NPC movement path trails from position history
|
||||
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
for eid in _npc_paths.keys():
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() < 2:
|
||||
continue
|
||||
for i in range(1, path.size()):
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
# Information state tags above visible NPCs from player_knowledge
|
||||
func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
if GameState.player_knowledge == null:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var knowledge: Dictionary = GameState.player_knowledge
|
||||
|
||||
# Build entity_id → knowledge entry lookup
|
||||
var kg_by_id: Dictionary = {}
|
||||
for entry in knowledge.get("entities", []):
|
||||
if entry is Dictionary and entry.has("entity_id"):
|
||||
kg_by_id[entry.entity_id] = entry
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if not kg_by_id.has(eid):
|
||||
continue
|
||||
var kg_entry: Dictionary = kg_by_id[eid]
|
||||
var confidence: String = kg_entry.get("confidence", "Unknown")
|
||||
var name_str: String = kg_entry.get("name", "?")
|
||||
var label := "%s [%s]" % [name_str, confidence]
|
||||
|
||||
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
|
||||
var tag_baseline := entity_screen.y - 18.0
|
||||
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
|
||||
var tag_rect := Rect2(
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_x := GRAPH_MARGIN
|
||||
var label_h := LINE_HEIGHT
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
var chart_w := GRAPH_W - 8.0
|
||||
var chart_h := GRAPH_H - 4.0
|
||||
|
||||
# Max value for scale
|
||||
var max_ms: float = TICK_WARN_MS
|
||||
for d in _tick_deltas:
|
||||
if float(d) > max_ms:
|
||||
max_ms = float(d)
|
||||
max_ms *= 1.1
|
||||
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
var prev_pt := Vector2.ZERO
|
||||
for i in range(n):
|
||||
var x := chart_left + chart_w * (float(i) / float(n - 1))
|
||||
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
|
||||
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
|
||||
var pt := Vector2(x, y)
|
||||
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
|
||||
if i > 0:
|
||||
draw_line(prev_pt, pt, color, 1.5)
|
||||
draw_circle(pt, 2.0, color)
|
||||
prev_pt = pt
|
||||
|
||||
# Average label
|
||||
var avg_ms := 0.0
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
う
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲactionゥMoveNorth
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env -S godot -s
|
||||
## gdUnit4 CI runner for the Settled Reach client.
|
||||
##
|
||||
## Run all client tests headlessly:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/
|
||||
##
|
||||
## Run a specific test file:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
|
||||
##
|
||||
## Exit code: 0 = all pass, non-zero = failures.
|
||||
##
|
||||
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
|
||||
## Test output format: JSON summary per D-030 sub-decision #6.
|
||||
extends SceneTree
|
||||
|
||||
var _cli_runner: GdUnitTestCIRunner
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
|
||||
_cli_runner = GdUnitTestCIRunner.new()
|
||||
root.add_child(_cli_runner)
|
||||
|
||||
|
||||
func _finalize() -> void:
|
||||
queue_delete(_cli_runner)
|
||||
@@ -0,0 +1,317 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -0,0 +1,334 @@
|
||||
## Sprint 18 — Examine result display (#174)
|
||||
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
|
||||
##
|
||||
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
|
||||
## Positioned in InsertOverlay (CanvasLayer 10).
|
||||
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
|
||||
##
|
||||
## Tests run against live Stig implementation (examine_display.gd).
|
||||
class_name TestExamineDisplaySprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
|
||||
|
||||
func _make_examine_display() -> Control:
|
||||
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
|
||||
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_result(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result parsing
|
||||
## (Now tests real implementation — not test-first stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (#174)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
|
||||
assert_that(GameState.current_examine_result).is_not_null()
|
||||
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
|
||||
## The overlay must auto-dismiss — the server never re-sends the same result.
|
||||
GameState.current_examine_result = _make_result()
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
|
||||
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
|
||||
## Two examine results in sequence — second replaces first.
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
|
||||
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
|
||||
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
|
||||
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_examine_display_instantiates_without_crash() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_that(display).is_not_null()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_show_result_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("show_result")).override_failure_message(
|
||||
"ExamineDisplay must have show_result(result: Dictionary) method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_dismiss_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("dismiss")).override_failure_message(
|
||||
"ExamineDisplay must have dismiss() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_is_active_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("is_active")).override_failure_message(
|
||||
"ExamineDisplay must have is_active() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_not_active_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"ExamineDisplay must start inactive (no result showing)"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_not_visible_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"ExamineDisplay must start invisible"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_active_after_show_result() -> void:
|
||||
## show_result() with valid text sets is_active() = true.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with text must set is_active() = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_visible_after_show_result() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"show_result() must set visible = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_empty_text_ignored() -> void:
|
||||
## show_result() with empty text must not activate (D-041: no empty observations).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with empty text must not activate the display"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_inactive_after_dismiss() -> void:
|
||||
## dismiss() immediately starts fade-out and sets _active = false.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).is_true()
|
||||
display.dismiss()
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"dismiss() must set is_active() = false immediately"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
|
||||
## dismiss() on an inactive display must be safe (no crash, no state corruption).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.dismiss() # called when not active
|
||||
assert_bool(display.is_active()).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_show_replaces_previous() -> void:
|
||||
## Second show_result() replaces first (only one result at a time).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result({"text": "First observation."}))
|
||||
display.show_result(_make_result({"text": "Second observation."}))
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() called twice must leave display active"
|
||||
).is_true()
|
||||
## Text label should reflect the second result
|
||||
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
|
||||
if text_label is RichTextLabel:
|
||||
assert_that(text_label.text).override_failure_message(
|
||||
"Second show_result() must replace the displayed text"
|
||||
).is_equal("Second observation.")
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: DISMISS_DELAY within spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dismiss_delay_within_spec() -> void:
|
||||
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.DISMISS_DELAY).override_failure_message(
|
||||
"DISMISS_DELAY must be 4–6 seconds per spec"
|
||||
).is_between(4.0, 6.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_confidence_alpha_dict_covers_all_levels() -> void:
|
||||
## All four confidence levels must have alpha mappings.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||
assert_bool(alpha_dict.has(level)).override_failure_message(
|
||||
"CONFIDENCE_ALPHA must map '%s'" % level
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_direct_is_highest() -> void:
|
||||
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
|
||||
"Direct confidence must have alpha 1.0 (brightest)"
|
||||
).is_equal_approx(1.0, 0.001)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_suspects_is_lowest() -> void:
|
||||
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
|
||||
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
|
||||
assert_float(suspects_alpha).override_failure_message(
|
||||
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
|
||||
).is_less(direct_alpha)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_all_values_valid() -> void:
|
||||
## All alpha values must be in [0.0, 1.0].
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
for key in display.CONFIDENCE_ALPHA:
|
||||
var alpha: float = display.CONFIDENCE_ALPHA[key]
|
||||
assert_float(alpha).override_failure_message(
|
||||
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
|
||||
).is_between(0.0, 1.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-interactive: mouse_filter must be IGNORE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_mouse_filter_ignore() -> void:
|
||||
## ExamineDisplay is non-interactive — must not consume mouse events.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_int(display.mouse_filter).override_failure_message(
|
||||
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fade constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_fade_in_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_IN).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_fade_out_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
@@ -0,0 +1,156 @@
|
||||
## GameState.apply_snapshot() tests — v2+ field coverage.
|
||||
##
|
||||
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
|
||||
## player_position). This file covers v2+ fields and derived state.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameState
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset fields touched by these tests to known defaults.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.game_time = {}
|
||||
GameState.nearby_interactions = []
|
||||
GameState.current_monologue = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# -- v2: game_time (D-031) -------------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_game_time() -> void:
|
||||
var snapshot := {
|
||||
"tick": 10,
|
||||
"entities": [],
|
||||
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.game_time).is_not_null()
|
||||
assert_that(GameState.game_time.get("day")).is_equal(3)
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
|
||||
|
||||
|
||||
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
|
||||
GameState.game_time = {"day": 2, "time_of_day": 360}
|
||||
GameState.apply_snapshot({"tick": 5, "entities": []})
|
||||
# No "game_time" key → field unchanged
|
||||
assert_that(GameState.game_time.get("day")).is_equal(2)
|
||||
|
||||
|
||||
# -- v2: player_facing (D-015) --------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_facing() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [],
|
||||
"player_facing": "Southeast",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.player_facing).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
GameState.player_facing = "West"
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.player_facing).is_equal("West")
|
||||
|
||||
|
||||
# -- v4: nearby_interactions (#404/#405) ----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||
var interactions := [
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
GameState.nearby_interactions = [{"entity_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- v5: current_monologue (#414) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_monologue() -> void:
|
||||
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||
|
||||
|
||||
func test_apply_snapshot_monologue_absent_clears_field() -> void:
|
||||
GameState.current_monologue = {"id": "old", "text": "Old line."}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
# -- v6: player_stance (#449, D-053) --------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_stance() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
|
||||
assert_that(GameState.player_stance).is_equal("Crouch")
|
||||
|
||||
|
||||
# -- v6: player_inventory (#449, D-065) -----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_inventory() -> void:
|
||||
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(1)
|
||||
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
|
||||
|
||||
|
||||
func test_apply_snapshot_inventory_absent_clears_list() -> void:
|
||||
GameState.player_inventory = [{"item_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Stationary tick counter (D-071) -------------------------------------
|
||||
|
||||
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
|
||||
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
|
||||
|
||||
func test_stationary_ticks_resets_on_player_movement() -> void:
|
||||
var s1 := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
var s2 := {
|
||||
"tick": 2,
|
||||
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(s1)
|
||||
GameState.apply_snapshot(s1) # stationary
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
GameState.apply_snapshot(s2) # moved → reset
|
||||
assert_int(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
|
||||
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_insert_active_false() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
|
||||
assert_bool(GameState.insert_active).is_false()
|
||||
|
||||
|
||||
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
|
||||
GameState.insert_active = false
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_bool(GameState.insert_active).is_true()
|
||||
@@ -0,0 +1,182 @@
|
||||
## D-030 Layer 1: Cross-language IPC fixture tests (#271)
|
||||
## Validates that Protocol.gd decodes the #271 named fixtures identically to Rust.
|
||||
## Fixtures generated by: cargo test --test gen_fixtures -- --ignored
|
||||
## Rust validation: server/tests/serialization.rs (fixture_* tests)
|
||||
class_name TestIpcFixtures
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const FIXTURE_DIR = "res://tests/fixtures/msgpack/"
|
||||
|
||||
|
||||
func _load_fixture(name: String) -> PackedByteArray:
|
||||
var path = FIXTURE_DIR + name + ".msgpack"
|
||||
var file = FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(file).is_not_null().override_failure_message(
|
||||
"Fixture not found: %s — run 'make fixtures' to regenerate" % path
|
||||
)
|
||||
return file.get_buffer(file.get_length())
|
||||
|
||||
|
||||
# -- snapshot_minimal ----------------------------------------------------------
|
||||
|
||||
func test_fixture_snapshot_minimal_version() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_tick() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.tick).is_equal(0)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_entity_count() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_entity_kind() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
var entity = snapshot.entities[0]
|
||||
assert_that(entity.entity_id).is_equal(1)
|
||||
# entity.kind is {"variant": "Player", "data": null} from _decode_enum_variant
|
||||
assert_that(entity.kind.variant).is_equal("Player")
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_no_monologue() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_no_dialogue() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.dialogue_response).is_null()
|
||||
|
||||
|
||||
# -- snapshot_full -------------------------------------------------------------
|
||||
|
||||
func test_fixture_snapshot_full_tick() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(42)
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_monologue_id() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue).is_not_null()
|
||||
assert_that(snapshot.current_monologue.id).is_equal("test_monologue_001")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_monologue_text() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue.text).is_equal("Something feels off about this place.")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_dialogue_speaker() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.dialogue_response).is_not_null()
|
||||
# dialogue_response has: line_id, text, speaker_entity_id (per protocol.gd v8 decode)
|
||||
assert_that(snapshot.dialogue_response.speaker_entity_id).is_equal(99)
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_inventory() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_inventory.size()).is_equal(1)
|
||||
assert_that(snapshot.player_inventory[0].name).is_equal("Forged Customs Cert")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_poi_list() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.poi_list.size()).is_equal(1)
|
||||
assert_that(snapshot.poi_list[0].poi_id).is_equal("docking_bay_7")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_player_knowledge_entity() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_knowledge).is_not_null()
|
||||
assert_that(snapshot.player_knowledge.entities.size()).is_equal(1)
|
||||
assert_that(snapshot.player_knowledge.entities[0].name).is_equal("Kael")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_player_knowledge_fact() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_knowledge.facts.size()).is_equal(1)
|
||||
assert_that(snapshot.player_knowledge.facts[0].fact_id).is_equal("poi.docking_bay_7")
|
||||
|
||||
|
||||
# -- player_input_move ---------------------------------------------------------
|
||||
|
||||
func test_fixture_player_input_move_tick() -> void:
|
||||
var bytes = _load_fixture("player_input_move")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input).is_not_null()
|
||||
assert_that(input.tick).is_equal(1)
|
||||
|
||||
|
||||
func test_fixture_player_input_move_action() -> void:
|
||||
var bytes = _load_fixture("player_input_move")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
# action is {"variant": "MoveNorth", "data": null} from _decode_enum_variant
|
||||
assert_that(input.action.variant).is_equal("MoveNorth")
|
||||
|
||||
|
||||
# -- player_input_interact -----------------------------------------------------
|
||||
|
||||
func test_fixture_player_input_interact_tick() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input).is_not_null()
|
||||
assert_that(input.tick).is_equal(2)
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_action() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
# Interact is a struct variant: {"variant": "Interact", "data": {"target_entity_id": 99, "verb": "Talk"}}
|
||||
assert_that(input.action.variant).is_equal("Interact")
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_target() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input.action.data.target_entity_id).is_equal(99)
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_verb() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input.action.data.verb).is_equal("Talk")
|
||||
|
||||
|
||||
# -- malformed -----------------------------------------------------------------
|
||||
|
||||
func test_fixture_malformed_snapshot_fails() -> void:
|
||||
var bytes = _load_fixture("malformed")
|
||||
# Intentionally truncated — decode_snapshot must return null (not crash)
|
||||
var result = Protocol.decode_snapshot(bytes)
|
||||
assert_that(result).is_null().override_failure_message(
|
||||
"malformed fixture should not decode as a valid ObserverSnapshot"
|
||||
)
|
||||
|
||||
|
||||
func test_fixture_malformed_input_fails() -> void:
|
||||
var bytes = _load_fixture("malformed")
|
||||
# Intentionally truncated — decode_player_input must return null (not crash)
|
||||
var result = Protocol.decode_player_input(bytes)
|
||||
assert_that(result).is_null().override_failure_message(
|
||||
"malformed fixture should not decode as a valid PlayerInput"
|
||||
)
|
||||
@@ -0,0 +1,320 @@
|
||||
## Sprint 18 — Minimap rendering (#151)
|
||||
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||
## D-049 (z-layer 6 = InsertOverlay)
|
||||
##
|
||||
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||
##
|
||||
## Tests run against live Stig implementation (minimap.gd).
|
||||
class_name TestMinimapSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"id": "poi_test_001",
|
||||
"x": 20,
|
||||
"y": 15,
|
||||
"poi_category": "location",
|
||||
"label": "Exit A",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.insert_active = true
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene and class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_minimap_instantiates_without_crash() -> void:
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_that(mm).is_not_null()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_minimap_is_minimap_renderer_class() -> void:
|
||||
## class_name MinimapRenderer in minimap.gd.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: D-015, visual parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_radius_constant() -> void:
|
||||
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||
).is_equal_approx(24.0, 0.01)
|
||||
|
||||
|
||||
func test_player_dot_radius_defined() -> void:
|
||||
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_poi_dot_radius_defined() -> void:
|
||||
## POI dot must be visible (> 0).
|
||||
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_player_dot_larger_than_poi_dot() -> void:
|
||||
## D-015: Player is always centered and visually distinct.
|
||||
## Player dot should be at least as large as POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _category_color() — D-013 POI category color mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_color_danger_is_hostile_color() -> void:
|
||||
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||
for cat in ["danger", "threat", "hostile"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
|
||||
func test_category_color_evidence_is_poi_color() -> void:
|
||||
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||
for cat in ["evidence", "note", "clue"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
|
||||
func test_category_color_contact_is_unknown_color() -> void:
|
||||
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||
for cat in ["contact", "npc", "person"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||
assert_that(color).override_failure_message(
|
||||
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_empty_string_defaults() -> void:
|
||||
## Empty category string → default color, no crash.
|
||||
var color: Color = MinimapRenderer._category_color("")
|
||||
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_case_insensitive() -> void:
|
||||
## Category matching is case-insensitive (uses to_lower()).
|
||||
var danger_lower := MinimapRenderer._category_color("danger")
|
||||
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||
assert_that(danger_lower).is_equal(danger_upper)
|
||||
assert_that(danger_lower).is_equal(danger_mixed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_insert_active() — D-049: insert layer visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_set_insert_active_false_hides_minimap() -> void:
|
||||
## When insert is inactive, minimap must be hidden.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(false) must hide the minimap"
|
||||
).is_false()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_set_insert_active_true_shows_minimap() -> void:
|
||||
## When insert is active, minimap must be visible.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
mm.set_insert_active(true)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(true) must show the minimap"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scene structural check: InsertOverlay/Minimap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
# Check for Minimap in InsertOverlay
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||
assert_that(insert_overlay != null).override_failure_message(
|
||||
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||
).is_true()
|
||||
if insert_overlay == null: return
|
||||
|
||||
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||
assert_that(minimap != null).override_failure_message(
|
||||
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||
).is_true()
|
||||
if minimap == null: return
|
||||
|
||||
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||
if insert_overlay == null: return
|
||||
assert_int(insert_overlay.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.discovered_pois integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||
"GameState must have 'discovered_pois' field (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [
|
||||
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||
],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||
## Snapshot with "discovered_pois" key also works.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"discovered_pois": [_make_poi()],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||
GameState.discovered_pois = [_make_poi()]
|
||||
GameState.apply_snapshot({"tick": 3})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_poi_category_field_present() -> void:
|
||||
## MinimapRenderer reads poi_category to determine shape/color.
|
||||
## Verify the wire format includes this field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_x_y_fields_present() -> void:
|
||||
## MinimapRenderer reads x, y for position calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||
"POI entries must have 'x' and 'y' coordinate fields"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color constants: all distinct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_colors_are_distinct() -> void:
|
||||
## All three primary category color groups must be visually distinct.
|
||||
var danger_color := MinimapRenderer._category_color("danger")
|
||||
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||
var contact_color := MinimapRenderer._category_color("contact")
|
||||
assert_that(danger_color).is_not_equal(evidence_color)
|
||||
assert_that(evidence_color).is_not_equal(contact_color)
|
||||
assert_that(danger_color).is_not_equal(contact_color)
|
||||
@@ -0,0 +1,208 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
_created_ids = []
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
for game_id in _created_ids:
|
||||
var path := "user://saves/" + game_id
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
_created_ids.clear()
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call new_game() and track the created directory for cleanup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _new_game() -> String:
|
||||
var game_id := SessionManager.new_game()
|
||||
if not game_id.is_empty():
|
||||
_created_ids.append(game_id)
|
||||
return game_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := _new_game()
|
||||
var id2 := _new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -0,0 +1,99 @@
|
||||
## Scene testing utilities for gdUnit4 tests.
|
||||
##
|
||||
## Loads a scene, instantiates it into the test suite's node tree,
|
||||
## and provides helpers for node existence, signal, and node-path queries.
|
||||
##
|
||||
## Usage (from a GdUnitTestSuite subclass):
|
||||
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
|
||||
## helper.assert_node_exists("World")
|
||||
## var world := helper.get_node_at("World")
|
||||
## helper.monitor_signal(world, "ready")
|
||||
## # ... trigger something ...
|
||||
## helper.assert_signal_emitted(world, "ready")
|
||||
##
|
||||
## Design constraints (D-030): server-free, no running autoload dependencies.
|
||||
class_name SceneHelper
|
||||
extends RefCounted
|
||||
|
||||
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
|
||||
var _scene: Node
|
||||
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
|
||||
var _signal_hits: Dictionary = {}
|
||||
|
||||
|
||||
## Load, instantiate, and attach a scene to the test suite's node tree.
|
||||
## The scene node is registered for auto-free by gdUnit4.
|
||||
## Returns a helper instance; fails the test if the scene cannot be loaded.
|
||||
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
|
||||
var helper := SceneHelper.new()
|
||||
helper._suite = suite
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
suite.assert_that(packed).override_failure_message(
|
||||
"SceneHelper: could not load scene at '%s'" % scene_path
|
||||
).is_not_null()
|
||||
return helper
|
||||
|
||||
helper._scene = packed.instantiate()
|
||||
suite.auto_free(helper._scene)
|
||||
suite.add_child(helper._scene)
|
||||
return helper
|
||||
|
||||
|
||||
## Returns the scene root node.
|
||||
func scene() -> Node:
|
||||
return _scene
|
||||
|
||||
|
||||
## Assert that a node at node_path exists under the scene root.
|
||||
## Fails the current test if the node is absent.
|
||||
func assert_node_exists(node_path: String) -> void:
|
||||
var node := _scene.get_node_or_null(NodePath(node_path))
|
||||
_suite.assert_that(node).override_failure_message(
|
||||
"SceneHelper: expected node at path '%s' — not found" % node_path
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Return the node at node_path under the scene root, or null if absent.
|
||||
func get_node_at(node_path: String) -> Node:
|
||||
return _scene.get_node_or_null(NodePath(node_path))
|
||||
|
||||
|
||||
## Begin tracking emissions of signal_name on node.
|
||||
## Must be called before the action that triggers the signal.
|
||||
## Fails the test if node does not have the named signal.
|
||||
func monitor_signal(node: Node, signal_name: String) -> void:
|
||||
if not node.has_signal(signal_name):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
|
||||
).is_true()
|
||||
return
|
||||
var key := _signal_key(node, signal_name)
|
||||
_signal_hits[key] = 0
|
||||
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
|
||||
# GDScript default-param lambdas handle being called with fewer args correctly.
|
||||
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
|
||||
_signal_hits[key] = _signal_hits.get(key, 0) + 1
|
||||
)
|
||||
|
||||
|
||||
## Assert that signal_name was emitted at least once since monitor_signal().
|
||||
## Fails the test if monitor_signal() was not called first, or if count is zero.
|
||||
func assert_signal_emitted(node: Node, signal_name: String) -> void:
|
||||
var key := _signal_key(node, signal_name)
|
||||
if not _signal_hits.has(key):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
|
||||
).is_true()
|
||||
return
|
||||
var count: int = _signal_hits[key]
|
||||
_suite.assert_int(count).override_failure_message(
|
||||
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
|
||||
signal_name, node.name, count
|
||||
]
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
static func _signal_key(node: Node, signal_name: String) -> String:
|
||||
return "%d:%s" % [node.get_instance_id(), signal_name]
|
||||
@@ -0,0 +1,53 @@
|
||||
extends Control
|
||||
## #258: Main menu — New Game / Continue / Quit.
|
||||
## New Game: generates per-game save directory (D-085), starts game.
|
||||
## Continue: loads most recent save directory (#554 full loading screen deferred).
|
||||
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TITLE_COLOR := Color("#c8d0e0")
|
||||
const SUBTITLE_COLOR := Color("#8890a0")
|
||||
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||
const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
_continue_btn.pressed.connect(_on_continue)
|
||||
_quit_btn.pressed.connect(_on_quit)
|
||||
_refresh_continue_state()
|
||||
|
||||
|
||||
func _refresh_continue_state() -> void:
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
_continue_btn.disabled = saves.is_empty()
|
||||
|
||||
|
||||
func _on_new_game() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
if game_id.is_empty():
|
||||
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
||||
return
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_continue() -> void:
|
||||
# #554: Full loading screen blocked until server SaveCommand lands.
|
||||
# For now: automatically load the most recent game directory.
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
return
|
||||
SessionManager.resume_game(saves[0].game_id)
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
@@ -91,7 +91,7 @@ func _draw() -> void:
|
||||
var dx: float = float(poi.x) - px
|
||||
var dy: float = float(poi.y) - py
|
||||
var dist: float = sqrt(dx * dx + dy * dy)
|
||||
var category: String = poi.get("category", "")
|
||||
var category: String = poi.get("poi_category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
# Quit to Menu button (#258: D-085 session management)
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
|
||||
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
|
||||
quit_btn.pressed.connect(_on_quit_to_menu)
|
||||
_container.add_child(quit_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
close()
|
||||
SessionManager.quit_to_menu()
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decision ID management — claim, query, and validate decision IDs.
|
||||
# Usage:
|
||||
# decision next [D|Q|R] Show next available ID
|
||||
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
|
||||
# decision check-dupes Check for duplicate IDs in markdown
|
||||
# decision sync Sync markdown -> DB
|
||||
exec python3 "$(dirname "$0")/decisions_sync.py" "$@"
|
||||
@@ -366,22 +366,133 @@ def sync(cfg):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID claiming — database is authority for ID allocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def next_id(cfg, prefix=None):
|
||||
"""Return the next available ID for a given prefix (D, Q, R) or all."""
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
result = {}
|
||||
prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"]
|
||||
for p in prefixes:
|
||||
# Check both DB and markdown files for the highest ID
|
||||
row = conn.execute(
|
||||
"SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num "
|
||||
"FROM decisions WHERE id LIKE ?",
|
||||
(f"{p}-%",),
|
||||
).fetchone()
|
||||
db_max = row["max_num"] if row and row["max_num"] else 0
|
||||
|
||||
# Also scan markdown files in case they're ahead of the DB
|
||||
md_max = 0
|
||||
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||
if filepath.name.lower() == "readme.md":
|
||||
continue
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE):
|
||||
num = int(m.group(1))
|
||||
if num > md_max:
|
||||
md_max = num
|
||||
|
||||
highest = max(db_max, md_max)
|
||||
next_num = highest + 1
|
||||
result[p] = f"{p}-{next_num:03d}"
|
||||
|
||||
return {"ok": True, **result}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def claim_id(cfg, prefix, domain, title):
|
||||
"""Claim the next available ID and insert a placeholder into the DB."""
|
||||
if prefix not in ("D", "Q", "R"):
|
||||
return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."}
|
||||
|
||||
type_map = {"D": "confirmed", "Q": "question", "R": "rejected"}
|
||||
status_map = {"D": "active", "Q": "open", "R": "active"}
|
||||
|
||||
nxt = next_id(cfg, prefix)
|
||||
if not nxt.get("ok"):
|
||||
return nxt
|
||||
|
||||
new_id = nxt[prefix]
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
|
||||
(new_id, type_map[prefix], domain, title, status_map[prefix],
|
||||
f"decisions/{domain}.md"),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "id": new_id, "domain": domain, "title": title}
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
return {"ok": False, "error": f"ID conflict: {exc}"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def check_dupes(cfg):
|
||||
"""Check for duplicate decision IDs across all markdown files."""
|
||||
# Pre-existing collisions too deeply embedded to renumber (139+ references).
|
||||
# New collisions are prevented by the claim workflow.
|
||||
KNOWN_EXCEPTIONS = {"D-035"}
|
||||
|
||||
id_locations = {} # id -> [(file, line_number)]
|
||||
warnings = []
|
||||
|
||||
for filepath in sorted(DECISIONS_DIR.glob("*.md")):
|
||||
if filepath.name.lower() == "readme.md":
|
||||
continue
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(text.split("\n"), 1):
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
did = m.group(1)
|
||||
if did not in id_locations:
|
||||
id_locations[did] = []
|
||||
id_locations[did].append((filepath.name, i))
|
||||
|
||||
dupes = {did: locs for did, locs in id_locations.items()
|
||||
if len(locs) > 1 and did not in KNOWN_EXCEPTIONS}
|
||||
|
||||
if dupes:
|
||||
for did, locs in sorted(dupes.items()):
|
||||
loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs)
|
||||
warnings.append(f"DUPLICATE {did}: {loc_str}")
|
||||
|
||||
return {
|
||||
"ok": len(dupes) == 0,
|
||||
"total_ids": len(id_locations),
|
||||
"duplicates": len(dupes),
|
||||
"known_exceptions": list(KNOWN_EXCEPTIONS),
|
||||
"details": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = """\
|
||||
Commonwealth Decisions Sync
|
||||
Commonwealth Decisions Sync & ID Management
|
||||
|
||||
Usage:
|
||||
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
||||
decisions_sync.py --help Show this help message
|
||||
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
||||
decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one)
|
||||
decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder
|
||||
decisions_sync.py check-dupes Check for duplicate IDs across markdown files
|
||||
decisions_sync.py --help Show this help message
|
||||
|
||||
Parses all markdown files in decisions/ (excluding README.md), extracts
|
||||
decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions
|
||||
and decision_refs tables.
|
||||
|
||||
Idempotent: safe to run repeatedly. References are rebuilt on every sync.
|
||||
ID claiming workflow:
|
||||
1. Agent calls 'claim D architecture "Per-game save dirs"'
|
||||
2. Gets back D-085 (or whatever is next)
|
||||
3. Agent writes D-085 in the appropriate domain file
|
||||
4. Pre-commit hook runs check-dupes to catch collisions
|
||||
|
||||
Config: {config}
|
||||
Schema: {schema}
|
||||
@@ -404,6 +515,18 @@ def main():
|
||||
|
||||
if cmd == "sync":
|
||||
result = sync(cfg)
|
||||
elif cmd == "next":
|
||||
result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None)
|
||||
elif cmd == "claim":
|
||||
if len(sys.argv) < 4:
|
||||
result = {"ok": False, "error": "Usage: claim <D|Q|R> <domain> [title]"}
|
||||
else:
|
||||
prefix = sys.argv[2].upper()
|
||||
domain = sys.argv[3]
|
||||
title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)"
|
||||
result = claim_id(cfg, prefix, domain, title)
|
||||
elif cmd == "check-dupes":
|
||||
result = check_dupes(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
|
||||
+5
-5
@@ -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 |
|
||||
| [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-084 |
|
||||
| [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 |
|
||||
| [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, 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
|
||||
|
||||
@@ -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)*
|
||||
|
||||
+22
-1
@@ -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)*
|
||||
|
||||
@@ -446,7 +446,7 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
|
||||
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
|
||||
|
||||
### D-084: Insert icon system — custom SVG, no icon font
|
||||
### D-086: Insert icon system — custom SVG, no icon font
|
||||
- **Date:** 2026-02-24
|
||||
- **Decision:** The insert overlay (z-layer 6) uses **custom SVG icons**, not an icon font. The v0.1 icon vocabulary (~12–15 shapes: stance indicators, perception mode icons, inventory item silhouettes, border arrows) is too small and too specific for a font. No available icon font (Material Symbols, Phosphor, Feather, Tabler) matches the insert's geometric/diegetic visual language — they all read as "app UI," not "neural lattice overlay." Custom SVGs authored to the insert's constraint set (clean geometry, minimal anchor points, `#c8d0e0` chrome color, 1px stroke base weight) ensure all insert elements feel generated by the same system.
|
||||
- **Why not an icon font:**
|
||||
@@ -467,4 +467,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
|
||||
---
|
||||
|
||||
*38 decisions. Last updated: 2026-02-24 (D-084: Insert icon system)*
|
||||
*38 decisions. Last updated: 2026-02-24 (D-086: Insert icon system)*
|
||||
|
||||
+61
-1
@@ -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)*
|
||||
|
||||
+28
-1
@@ -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)*
|
||||
|
||||
+19
-3
@@ -62,11 +62,27 @@ The server must be running before the client connects (subprocess launch will be
|
||||
### Test
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
make test-server # cargo test in server/
|
||||
make test-client # gdUnit4 tests (headless runner pending)
|
||||
make test # Run all tests (test-server + test-client)
|
||||
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
|
||||
make test-client # Godot tests via tests/run-godot (gdUnit4 headless, JSON summary)
|
||||
```
|
||||
|
||||
The IPC test layers (D-030) have dedicated targets:
|
||||
|
||||
```bash
|
||||
make test-ipc-fixtures # Layer 1: serialization round-trip fixtures
|
||||
make test-ipc-protocol # Layer 2: mock LocalBridge protocol tests
|
||||
make test-ipc-integration # Layer 3: real subprocess round-trip (+ benchmark when ready)
|
||||
make test-ipc-benchmark # IPC latency benchmark (blocked: #555/#556 handshake)
|
||||
```
|
||||
|
||||
Each `tests/run-*` script outputs a JSON summary to stdout and streams progress to stderr:
|
||||
```json
|
||||
{"suite":"rust","total":42,"passed":42,"failed":0,"duration_ms":1230}
|
||||
```
|
||||
|
||||
All scripts accept `--filter <name>` to run a subset of tests. They are whitelistable for agent use (no TTY prompts, no interactive input).
|
||||
|
||||
Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030).
|
||||
|
||||
### Cross-Encoder Fixtures
|
||||
|
||||
@@ -60,7 +60,7 @@ Main menu, pause, save/load, options.
|
||||
|-----------|---------|-------------------|
|
||||
| [v01-main-menu](menus/v01-main-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (functional warmth style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (two-character proof — character select) |
|
||||
| [v01-pause-menu](menus/v01-pause-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
|
||||
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) |
|
||||
| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-085](../../../decisions/architecture.md#d-085-per-game-save-directory-structure) (per-game save dirs), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice) |
|
||||
| [v10-main-menu](menus/v10-main-menu.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-036](../../../decisions/content.md#d-036-sova-transit-district--krenn-system-as-v01-setting) (setting — Sova Transit District), [D-013](../../../decisions/scope.md#d-013-diegetic-insertpoi-navigation-system) (diegetic insert — in-fiction menu) |
|
||||
| [v10-options-full](menus/v10-options-full.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-068](../../../decisions/architecture.md#d-068-5-bus-audio-architecture) (5-bus audio — per-bus volume controls), [D-069](../../../decisions/perception.md#d-069-audio-dip-profiles-for-dialogue-and-confrontation) (audio dip profiles) |
|
||||
|
||||
|
||||
@@ -37,156 +37,220 @@
|
||||
"tab-save": {
|
||||
"type": "Rectangle",
|
||||
"left": 140, "top": 116, "width": 120, "height": 32,
|
||||
"fillColor": "#1a2030",
|
||||
"strokeColor": "#c8d0e0",
|
||||
"fillColor": "#0d1018",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 0, 0]
|
||||
},
|
||||
"tab-save-text": {
|
||||
"type": "Text",
|
||||
"left": 156, "top": 125,
|
||||
"text": "SAVE",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tab-load": {
|
||||
"type": "Rectangle",
|
||||
"left": 264, "top": 116, "width": 120, "height": 32,
|
||||
"fillColor": "#0d1018",
|
||||
"strokeColor": "#333340",
|
||||
"fillColor": "#1a2030",
|
||||
"strokeColor": "#c8d0e0",
|
||||
"corners": [2, 2, 0, 0]
|
||||
},
|
||||
"tab-load-text": {
|
||||
"type": "Text",
|
||||
"left": 280, "top": 125,
|
||||
"text": "LOAD",
|
||||
"fontColor": "#556677",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 13
|
||||
},
|
||||
"save-slot-1-active": {
|
||||
|
||||
"game-1-header": {
|
||||
"type": "Rectangle",
|
||||
"left": 140, "top": 156, "width": 860, "height": 72,
|
||||
"left": 140, "top": 156, "width": 860, "height": 32,
|
||||
"fillColor": "#151a24",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 0, 0]
|
||||
},
|
||||
"game-1-title": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 165,
|
||||
"text": "\u25bc DETECTIVE \u2014 Day 3 // Sova Transit // last played: today",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 12
|
||||
},
|
||||
"game-1-count": {
|
||||
"type": "Text",
|
||||
"left": 920, "top": 165,
|
||||
"text": "3 saves",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"qs-row": {
|
||||
"type": "Rectangle",
|
||||
"left": 158, "top": 192, "width": 842, "height": 62,
|
||||
"fillColor": "#1a2030",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [2, 2, 2, 2]
|
||||
},
|
||||
"save-slot-1-accent": {
|
||||
"qs-accent": {
|
||||
"type": "Rectangle",
|
||||
"left": 140, "top": 156, "width": 3, "height": 72,
|
||||
"left": 158, "top": 192, "width": 3, "height": 62,
|
||||
"fillColor": "#c8d8f0",
|
||||
"strokeColor": "#c8d8f0"
|
||||
},
|
||||
"slot-1-date": {
|
||||
"qs-label": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 162,
|
||||
"text": "AUTOSAVE // Day 1, 07:42",
|
||||
"left": 170, "top": 200,
|
||||
"text": "QUICKSAVE // Day 3, 14:22",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 13
|
||||
},
|
||||
"qs-location": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 218,
|
||||
"text": "The Terminal \u2014 afternoon shift",
|
||||
"fontColor": "#8899aa",
|
||||
"fontSize": 12
|
||||
},
|
||||
"qs-timestamp": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 234,
|
||||
"text": "saved: 2026-02-25 16:31",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 11
|
||||
},
|
||||
"qs-actions": {
|
||||
"type": "Text",
|
||||
"left": 840, "top": 212,
|
||||
"text": "[Enter] Load\n[F6] Quickload",
|
||||
"fontColor": "#c8d8f0",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"auto-row": {
|
||||
"type": "Rectangle",
|
||||
"left": 158, "top": 260, "width": 842, "height": 62,
|
||||
"fillColor": "#0e1118",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 2, 2]
|
||||
},
|
||||
"auto-label": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 268,
|
||||
"text": "AUTOSAVE // Day 3, 13:45",
|
||||
"fontColor": "#8899aa",
|
||||
"fontSize": 13
|
||||
},
|
||||
"auto-location": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 286,
|
||||
"text": "Corridor B-7",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 12
|
||||
},
|
||||
"auto-timestamp": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 302,
|
||||
"text": "saved: 2026-02-25 16:15",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"slot-1-row": {
|
||||
"type": "Rectangle",
|
||||
"left": 158, "top": 328, "width": 842, "height": 62,
|
||||
"fillColor": "#0e1118",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 2, 2]
|
||||
},
|
||||
"slot-1-label": {
|
||||
"type": "Text",
|
||||
"left": 170, "top": 336,
|
||||
"text": "SLOT 1 // Day 2, 22:10",
|
||||
"fontColor": "#8899aa",
|
||||
"fontSize": 13
|
||||
},
|
||||
"slot-1-location": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 180,
|
||||
"text": "The Terminal — morning shift // Detective",
|
||||
"fontColor": "#8899aa",
|
||||
"left": 170, "top": 354,
|
||||
"text": "Hab quarters \u2014 evening",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 12
|
||||
},
|
||||
"slot-1-timestamp": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 198,
|
||||
"text": "saved: 2026-02-23 14:31",
|
||||
"fontColor": "#556677",
|
||||
"left": 170, "top": 370,
|
||||
"text": "saved: 2026-02-25 14:48",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 11
|
||||
},
|
||||
"slot-1-actions": {
|
||||
"type": "Text",
|
||||
"left": 860, "top": 175,
|
||||
"text": "[Enter] Overwrite / Load",
|
||||
"fontColor": "#c8d8f0",
|
||||
"fontSize": 12
|
||||
},
|
||||
"save-slot-2": {
|
||||
|
||||
"game-2-header": {
|
||||
"type": "Rectangle",
|
||||
"left": 140, "top": 236, "width": 860, "height": 72,
|
||||
"fillColor": "#0e1118",
|
||||
"left": 140, "top": 404, "width": 860, "height": 32,
|
||||
"fillColor": "#111520",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 2, 2]
|
||||
},
|
||||
"slot-2-date": {
|
||||
"game-2-title": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 250,
|
||||
"text": "SLOT 2 // Day 1, 06:15",
|
||||
"left": 152, "top": 413,
|
||||
"text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday",
|
||||
"fontColor": "#8899aa",
|
||||
"fontSize": 13
|
||||
},
|
||||
"slot-2-location": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 268,
|
||||
"text": "Arrival — entering Sova Transit // Detective",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 12
|
||||
},
|
||||
"slot-2-timestamp": {
|
||||
"game-2-count": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 286,
|
||||
"text": "saved: 2026-02-23 13:10",
|
||||
"left": 920, "top": 413,
|
||||
"text": "2 saves",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 11
|
||||
},
|
||||
"save-slot-3": {
|
||||
|
||||
"game-3-header": {
|
||||
"type": "Rectangle",
|
||||
"left": 140, "top": 316, "width": 860, "height": 72,
|
||||
"fillColor": "#0e1118",
|
||||
"left": 140, "top": 444, "width": 860, "height": 32,
|
||||
"fillColor": "#111520",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [2, 2, 2, 2]
|
||||
},
|
||||
"slot-3-date": {
|
||||
"game-3-title": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 330,
|
||||
"text": "SLOT 3 // Day 1, 07:30",
|
||||
"left": 152, "top": 453,
|
||||
"text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20",
|
||||
"fontColor": "#8899aa",
|
||||
"fontSize": 13
|
||||
},
|
||||
"slot-3-location": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 348,
|
||||
"text": "Corridor B-7 — Kael spotted // Smuggler",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 12
|
||||
},
|
||||
"slot-3-timestamp": {
|
||||
"game-3-count": {
|
||||
"type": "Text",
|
||||
"left": 152, "top": 366,
|
||||
"text": "saved: 2026-02-23 12:48",
|
||||
"left": 920, "top": 453,
|
||||
"text": "5 saves",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 11
|
||||
},
|
||||
"empty-slots-label": {
|
||||
|
||||
"footer-note": {
|
||||
"type": "Text",
|
||||
"left": 140, "top": 400,
|
||||
"text": "SLOTS 4-8 — empty",
|
||||
"fontColor": "#2a3040",
|
||||
"fontSize": 12
|
||||
},
|
||||
"save-note": {
|
||||
"type": "Text",
|
||||
"left": 140, "top": 660,
|
||||
"text": "Autosave on: zone transitions, conversation ends, significant events",
|
||||
"left": 140, "top": 660, "width": 860,
|
||||
"text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 11
|
||||
"fontSize": 11,
|
||||
"wordWrap": true
|
||||
},
|
||||
"annotation-title": {
|
||||
"type": "Text",
|
||||
"left": 16, "top": 720,
|
||||
"text": "v0.1 SAVE / LOAD SCREEN",
|
||||
"text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)",
|
||||
"fontColor": "#556677",
|
||||
"fontSize": 11
|
||||
},
|
||||
"annotation-notes": {
|
||||
"type": "Text",
|
||||
"left": 16, "top": 734, "width": 1100,
|
||||
"text": "Tab UI: Save / Load. Slots show: timestamp, in-game time+location, character. Autosave shown separately. Active slot has accent bar. No screenshots in v0.1.",
|
||||
"text": "Games grouped by directory (D-085). Expand to see saves. QUICKSAVE + AUTOSAVE are system slots; manual slots below. SAVE tab shows current game only. F5/F6 global hotkeys for quicksave/quickload.",
|
||||
"fontColor": "#3a4455",
|
||||
"fontSize": 10,
|
||||
"wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,122 @@
|
||||
# Sprint 19: Persist — CI Tasks
|
||||
|
||||
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||
|
||||
**Branch:** `ci`
|
||||
**Agents:** Hoshe (QA/CI), Oscar (networking)
|
||||
|
||||
## Carry-over from Sprint 18
|
||||
|
||||
None.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #270 | Test runner bash scripts | — |
|
||||
| #556 | Protocol version handshake: client | #555 (server) |
|
||||
| #342 | IPC round-trip timing benchmark | #555, #556 |
|
||||
| #271 | IPC serialization fixture files | #270 |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack codec, SimBridge trait), D-030 (three-layer test architecture: fixture / mock-protocol / real-subprocess)
|
||||
|
||||
## Notes
|
||||
|
||||
### #270 — Test runner bash scripts
|
||||
|
||||
The test infrastructure has no standardized entry points for CI or agents to invoke. This ticket ships the runner layer.
|
||||
|
||||
What this ticket must deliver:
|
||||
Six scripts at `tests/`:
|
||||
1. `tests/run-rust` — runs `cargo test` in `server/`, exits 0/non-zero, JSON stdout summary
|
||||
2. `tests/run-godot` — runs GUT headlessly (`godot --headless -s client/tests/run_gut.gd`), exits 0/non-zero
|
||||
3. `tests/run-ipc-fixtures` — Layer 1: reads fixture files from `tests/fixtures/`, validates via Rust + GDScript, exits 0/non-zero
|
||||
4. `tests/run-ipc-protocol` — Layer 2: runs mock subprocess protocol state machine tests
|
||||
5. `tests/run-ipc-integration` — Layer 3: starts real server subprocess, runs full round-trip, kills it
|
||||
6. `tests/run-all` — invokes all five in order, collects exit codes, reports JSON summary
|
||||
|
||||
Script requirements per ticket description: exit code 0/non-zero, structured JSON stdout, accepts filter arguments (`--filter test_name`), no interactive input, whitelistable for Claude Code agents (no TTY prompts).
|
||||
|
||||
JSON stdout format (consistent across all scripts):
|
||||
```json
|
||||
{"suite": "rust", "total": 42, "passed": 42, "failed": 0, "duration_ms": 1230}
|
||||
```
|
||||
|
||||
These scripts are the entry points that `make ci-server`, `make ci-client`, and future CI pipelines call. Coordinate with Makefile targets in `docs/DEVOPS.md`.
|
||||
|
||||
### #556 — Protocol version handshake: client
|
||||
|
||||
Blocked by #555 (server must send `HandshakeMessage` first).
|
||||
|
||||
What this ticket must deliver:
|
||||
- `client/scripts/protocol/local_bridge.gd` (or `server_process.gd`): after starting the server subprocess, read the first framed message from the IPC channel
|
||||
- Validate it is a `HandshakeMessage` with `protocol_version == Protocol.PROTOCOL_VERSION` (14)
|
||||
- If mismatch: log error "Protocol version mismatch: server=%d, client=%d", emit a `handshake_failed` signal, shut down the server process gracefully
|
||||
- If match: emit `handshake_complete`, begin normal tick loop
|
||||
- Add a timeout: if no handshake message received within 5 seconds of process start, treat as mismatch
|
||||
|
||||
Current state: `client/scripts/protocol/protocol.gd` already checks `version` in `decode_snapshot()` and logs a mismatch. That check is per-snapshot. The handshake is the startup-time equivalent — validate once at connection, not per tick.
|
||||
|
||||
Files: `client/scripts/protocol/local_bridge.gd`, `client/scripts/protocol/server_process.gd`.
|
||||
|
||||
### #342 — IPC round-trip timing benchmark
|
||||
|
||||
Sprint exit criterion. Measures the complete latency path from server serialization to client scene update.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A benchmark script `tests/run-ipc-benchmark` that:
|
||||
1. Starts the server subprocess
|
||||
2. Waits for handshake (#555/#556)
|
||||
3. Sends N `PlayerInput` messages (N = 100 by default)
|
||||
4. Measures from `rmp_serde::to_vec` (server) to scene update completion (client)
|
||||
5. Reports p50/p95/p99 latencies in milliseconds
|
||||
6. Flags if any percentile exceeds 5ms threshold
|
||||
- Output JSON: `{"p50_ms": 1.2, "p95_ms": 2.8, "p99_ms": 4.1, "threshold_ms": 5, "passed": true}`
|
||||
- The benchmark is run as part of `tests/run-ipc-integration` in Layer 3
|
||||
|
||||
Implementation approach: server-side timestamps in `ObserverSnapshot` (add `server_emit_tick_ms` field, stripped in production builds), client records receive timestamp via `Time.get_ticks_msec()`. Delta = client receive - server emit.
|
||||
|
||||
Blocked by #555 and #556 — benchmark requires a working handshake before timing can start cleanly.
|
||||
|
||||
### #271 — IPC serialization fixture files
|
||||
|
||||
Layer 1 test data: pre-generated `.msgpack` fixture files that both Rust and GDScript can read to verify cross-language serialization compatibility.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A Rust binary (or test in `server/src/`) that generates fixtures to `tests/fixtures/`:
|
||||
- `snapshot_minimal.msgpack` — minimal valid `ObserverSnapshot` (version=14, tick=0, one entity)
|
||||
- `snapshot_full.msgpack` — all optional fields populated (monologue, dialogue, inventory, POIs, KG dump)
|
||||
- `player_input_move.msgpack` — `PlayerInput { tick: 1, action: MoveNorth }`
|
||||
- `player_input_interact.msgpack` — `PlayerInput { tick: 2, action: Interact { target: 99, verb: "Talk" } }`
|
||||
- `malformed.msgpack` — intentionally truncated bytes (tests error handling)
|
||||
- A GDScript test `client/tests/test_ipc_fixtures.gd` that reads each `.msgpack` fixture file, decodes via `Protocol.decode_snapshot()` / `Protocol.decode_player_input()`, and asserts expected field values
|
||||
- Cross-language verification: the same byte stream decoded by both Rust and GDScript must produce identical field values
|
||||
|
||||
The fixture generator is run once (manually or in CI pre-step) to produce the committed `.msgpack` files. The files live at `tests/fixtures/` and are committed to the repo.
|
||||
|
||||
Blocked by #270 — fixture tests are invoked by `tests/run-ipc-fixtures`.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#555 (server: protocol handshake) → #556 (ci: protocol handshake: client)
|
||||
#555 + #556 → #342 (IPC benchmark: requires working handshake)
|
||||
|
||||
#270 (test runner scripts) → #271 (fixture files: invoked by run-ipc-fixtures)
|
||||
|
||||
Parallel starts: #270, #555 (server-side) — both unblocked week 1
|
||||
#556 starts after #555 is at review
|
||||
#271 starts after #270 merges
|
||||
#342 starts after #555 + #556 both land
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): description" --description "body" --base main --head ci
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# Sprint 19: Persist — Client Tasks
|
||||
|
||||
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||
|
||||
**Branch:** `client`
|
||||
**Agents:** Stig (UI), Oscar (networking)
|
||||
|
||||
## Carry-over from Sprint 18
|
||||
|
||||
None. Sprint 18 closed clean.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #554 | Save/load: client UI | #553 (server) |
|
||||
| #258 | Game session management | — |
|
||||
| #205 | GDScript test framework setup | — |
|
||||
| #206 | Scene testing utilities | #205 |
|
||||
| #348 | Debug visualization overlay | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack), D-085 (per-game save directory structure)
|
||||
- `decisions/questions.md` — Q-029 (save file format design — open, Sprint 19 uses MessagePack quick-and-dirty format)
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
- **Q-029: Save file format design** — Sprint 19 ships MessagePack quick-and-dirty format. Do not over-engineer the loading screen metadata. A readable directory name (`<timestamp>-<seed>/`) per D-085 is sufficient for v0.1. The full versioning/migration design is tracked in Q-029 for a later sprint.
|
||||
|
||||
## Notes
|
||||
|
||||
### #554 — Save/load: client UI
|
||||
|
||||
Blocked by #553 (server must implement `SaveCommand`/`LoadCommand` IPC messages before client can wire F5/F6).
|
||||
|
||||
What this ticket must deliver:
|
||||
- F5 key mapped in `client/scripts/autoloads/input_mapper.gd` to send a `SaveGame` IPC action to the server with the active game directory path (`user://saves/<game-id>/quicksave.sav`)
|
||||
- F6 key mapped to send `LoadGame` IPC action with the same path
|
||||
- Server responds with `SaveComplete`/`LoadComplete` — client shows a brief HUD notification ("Saved" / "Loading...")
|
||||
- Loading screen scene: reads `user://saves/` directory, lists subdirectories sorted by last-modified (most recent first), shows most recent save filename per game directory per D-085
|
||||
- F6 from the main menu opens the loading screen
|
||||
- The active `game-id` is tracked in `GameState` autoload (add `current_game_id: String`)
|
||||
|
||||
Integration points: `client/scripts/autoloads/input_mapper.gd` (key bindings), `client/scripts/autoloads/game_state.gd` (current_game_id field), `client/scripts/protocol/` (new IPC message encoding), `client/scripts/ui/` (loading screen scene).
|
||||
|
||||
Save directory path per D-085: `user://saves/<timestamp>-<seed>/` where game-id is created on New Game (#258). F5 quicksave writes to `user://saves/<game-id>/quicksave.sav`. Loading screen lists directories sorted by `FileAccess.get_modified_time()`.
|
||||
|
||||
Wireframe reference: `docs/design/wireframes/menus/v01-save-load.png`.
|
||||
|
||||
### #258 — Game session management
|
||||
|
||||
New Game creates the per-game save directory before any save occurs (D-085 requirement: "directory created on New Game — even before the first save, so the path exists for quicksave/autosave").
|
||||
|
||||
What this ticket must deliver:
|
||||
- `GameState.current_game_id: String` — format `<timestamp>-<seed>` (e.g. `20260225-143022-a7b3f1`)
|
||||
- On "New Game": generate game-id (timestamp + RNG hex suffix), create `user://saves/<game-id>/` directory via `DirAccess.make_dir_recursive()`
|
||||
- On "Continue" / loading screen selection: set `current_game_id` from the selected directory name
|
||||
- "Quit to menu" flow: prompt "Save before quitting?" — F5 save if confirmed
|
||||
- Wire the game-id into the `SimBridge` startup: server subprocess launched with `--game-id <id>` argument (or equivalent) so server can log with the same ID
|
||||
|
||||
Integration points: `client/scripts/autoloads/game_state.gd` (new fields), `client/scripts/protocol/server_process.gd` (subprocess launch args), `client/scripts/ui/` (main menu scene: New Game / Continue buttons).
|
||||
|
||||
Note: `game_state.gd` is already the largest autoload with 300+ lines. Keep game session logic in a thin wrapper on `GameState` — do not add another 100-line block directly. Consider a `session_manager.gd` helper if the logic exceeds 40 lines.
|
||||
|
||||
### #205 — GDScript test framework setup
|
||||
|
||||
The project has no GDScript test infrastructure yet. The Godot client has no equivalent of `cargo test`.
|
||||
|
||||
What this ticket must deliver:
|
||||
- Install and configure **GUT (Godot Unit Test)** as the GDScript test framework — it has the best Godot 4 support and is actively maintained
|
||||
- Create `client/tests/` as the test root directory
|
||||
- `client/tests/run_gut.gd`: the GUT runner script that CI can invoke headlessly (`godot --headless -s client/tests/run_gut.gd`)
|
||||
- Exit code 0 = all pass, non-zero = failures — required for CI integration (#270 test runner scripts)
|
||||
- A single smoke test `client/tests/test_protocol.gd`: verifies `Protocol.decode_snapshot(bytes)` returns non-null for a minimal valid msgpack fixture
|
||||
|
||||
GUT installation: add as a Godot addon. Check if there is already an `addons/` directory in `client/`.
|
||||
|
||||
### #206 — Scene testing utilities
|
||||
|
||||
Blocked by #205 (GUT must be installed first).
|
||||
|
||||
What this ticket must deliver:
|
||||
- `client/tests/util/scene_helper.gd`: loads a scene file by path, instantiates it into a temporary viewport, provides `assert_node_exists(path)`, `assert_signal_emitted(node, signal_name)`, and `get_node_at(path)` helpers
|
||||
- `client/tests/test_game_state.gd`: tests for `GameState.apply_snapshot()` — verify that a snapshot dictionary with known fields updates the correct `GameState` fields
|
||||
- `client/tests/test_protocol.gd` (extend from #205 smoke test): add roundtrip test for `Protocol.encode_player_input()` and `Protocol.decode_player_input()`
|
||||
|
||||
These utilities are the scaffolding for all future client tests. Keep them minimal and dependency-free — do not require a running server.
|
||||
|
||||
### #348 — Debug visualization overlay
|
||||
|
||||
Dev tool (F3 toggle). The stub `client/scripts/ui/debug_overlay.gd` already exists.
|
||||
|
||||
What this ticket must deliver:
|
||||
- Extend `debug_overlay.gd` to draw on a `CanvasLayer` above the game world:
|
||||
- **Pathfinding waypoints**: draw lines between waypoint positions from `GameState.visible_entities` (entities with kind `Npc` — estimate waypoints from position delta between ticks)
|
||||
- **Line-of-sight rays**: draw lines from player position to each visible entity
|
||||
- **Vision cone boundary**: draw the forward/peripheral arc boundary using `GameState.visibility_sectors`
|
||||
- **Information state tags**: draw confidence label (Suspects/KnowsOf/KnowsDetails/Direct) above each visible NPC from `GameState.player_knowledge`
|
||||
- **Tick timing graph**: small line chart in corner showing tick delta over the last 30 ticks
|
||||
- F3 toggle: connected to `InputMapper` action `toggle_debug_overlay`
|
||||
- Debug overlay is **dev-only**: compiled out in export builds via `OS.is_debug_build()` check
|
||||
|
||||
Integration points: `client/scripts/autoloads/game_state.gd` (data source), `client/scripts/autoloads/input_mapper.gd` (F3 action), `client/scripts/ui/debug_overlay.gd` (extend existing stub).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#205 (GDScript test framework) → #206 (scene testing utilities)
|
||||
|
||||
#258 (game session management) → #554 (save/load client UI: needs current_game_id)
|
||||
#553 (server, ECS extraction) → #554 (save/load client UI: needs IPC commands)
|
||||
|
||||
#348 (debug overlay) → standalone, parallel track
|
||||
```
|
||||
|
||||
Parallel starts: #258, #205, #348 all unblocked week 1.
|
||||
#554 starts after #553 (server) reaches review stage and #258 lands.
|
||||
#206 starts after #205 merges.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
# Sprint 19: Persist — Joint Briefing
|
||||
|
||||
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||
|
||||
**Sprint:** 19
|
||||
**Status:** planning → active
|
||||
|
||||
## Pre-Sprint
|
||||
|
||||
Before implementation begins, no schema work is needed — `SaveStateV1` is already defined (#256, done). However, the following IPC protocol additions must be agreed between server and client **before either side implements**:
|
||||
|
||||
| Item | Owner | Needed by |
|
||||
|------|-------|-----------|
|
||||
| `HandshakeMessage` wire format | server (#555) | client (#556) |
|
||||
| `SaveCommand` / `LoadCommand` IPC message variants | server (#553) | client (#554) |
|
||||
| `SaveComplete` / `LoadComplete` response format | server (#553) | client (#554) |
|
||||
| Fixture file format and field names | ci (#271) | all teams |
|
||||
|
||||
Server team: define these in `server/src/bridge/types.rs` first (as Rust structs + serde). CI team + client team: implement against the published definitions. Do not start #556 or #554 until #555 and #553 respectively reach review.
|
||||
|
||||
## Team Allocation
|
||||
|
||||
| Team | Tickets | Count |
|
||||
|------|---------|-------|
|
||||
| server | #553, #96, #97, #98, #200, #272, #555 | 7 |
|
||||
| client | #554, #258, #205, #206, #348 | 5 |
|
||||
| ci | #270, #556, #342, #271 | 4 |
|
||||
|
||||
## Cross-Team Dependencies
|
||||
|
||||
```
|
||||
server #555 (handshake: server)
|
||||
→ ci #556 (handshake: client)
|
||||
→ ci #342 (IPC benchmark)
|
||||
|
||||
server #553 (ECS extraction)
|
||||
→ client #554 (save/load UI)
|
||||
|
||||
server #200 (test module org)
|
||||
→ server #272 (info boundary tests)
|
||||
|
||||
client #205 (GDScript test framework)
|
||||
→ client #206 (scene testing utilities)
|
||||
→ ci #271 (fixture files need GDScript reader)
|
||||
|
||||
ci #270 (test runner scripts)
|
||||
→ ci #271 (fixture tests invoked by run-ipc-fixtures)
|
||||
```
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
When Sprint 19 is done, the following must all be observable:
|
||||
|
||||
1. **Save/load round-trip**: Press F5 in-game → file appears at `user://saves/<game-id>/quicksave.sav` in MessagePack format. Press F6 → game state restored from file (tick, entities, player knowledge match pre-save state).
|
||||
|
||||
2. **Per-game directory**: Starting a New Game creates `user://saves/<timestamp>-<seed>/` before any save occurs. The loading screen lists this directory.
|
||||
|
||||
3. **Tier eviction**: Spawn 90+ NPCs (above Active cap of 80). `ActiveSim` count stabilizes at ≤80 with the excess evicted to `BackgroundSim`/`StateSaved`. Scope-tagged NPCs (KnownContact, Colleague) remain Active regardless.
|
||||
|
||||
4. **Protocol handshake**: Starting the server subprocess: first IPC message is a `HandshakeMessage`. Version mismatch (force by temporarily changing server `PROTOCOL_VERSION`) produces an error and clean shutdown — no crash.
|
||||
|
||||
5. **Test infrastructure**: `tests/run-all` exits 0 with all suites passing. `tests/run-ipc-fixtures` reads committed `.msgpack` files and validates both Rust and GDScript decode them identically. `cargo test` in `server/` includes information boundary negative tests that assert absence of leakage.
|
||||
|
||||
6. **Debug overlay**: F3 in-game toggles the debug canvas showing vision cone arcs, entity LOS rays, NPC knowledge confidence labels, and tick timing graph.
|
||||
|
||||
## Test Plan (D-030)
|
||||
|
||||
| Layer | Runner | Tickets | When |
|
||||
|-------|--------|---------|------|
|
||||
| Layer 1: Fixture serialization | `tests/run-ipc-fixtures` | #271, #200 | Every edit |
|
||||
| Layer 1: Unit tests (Rust) | `tests/run-rust` | #272, #96, #97, #98 | Every edit |
|
||||
| Layer 1: Unit tests (GDScript) | `tests/run-godot` | #205, #206 | Every edit |
|
||||
| Layer 2: Mock protocol | `tests/run-ipc-protocol` | #555, #556 | Every PR |
|
||||
| Layer 3: Real subprocess | `tests/run-ipc-integration` | #342, #553/#554 | Daily/pre-merge |
|
||||
|
||||
All layers must pass before any PR merges. `make ci` invokes `tests/run-all`.
|
||||
|
||||
## Key Decisions Reference
|
||||
|
||||
| Decision | Domain file | Relevant to |
|
||||
|----------|------------|-------------|
|
||||
| D-010: Determinism + info boundaries | architecture.md | #272, #96, #553 |
|
||||
| D-020: IPC architecture, MessagePack | architecture.md | #553, #554, #555, #556, #342, #271 |
|
||||
| D-026: Simulation tiers, timestamp eviction, scope tags | architecture.md | #96, #97, #98 |
|
||||
| D-030: Three-layer test architecture | architecture.md | #200, #270, #271, #272, #342 |
|
||||
| D-085: Per-game save directory structure | architecture.md | #554, #258, #553 |
|
||||
| Q-029: Save file format (open) | questions.md | #553 (quick-and-dirty MessagePack for now) |
|
||||
|
||||
## Risk Register
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| ECS extraction misses components (#553) | Medium | High | #272 info boundary tests catch leakage; fixture roundtrip (#271) catches missing fields |
|
||||
| IPC protocol mismatch between #555 and #556 | Low | High | Define wire types in Rust first, share definition doc before client implements |
|
||||
| GUT framework incompatible with Godot 4.x version in use (#205) | Low | Medium | Verify GUT version before full installation; fallback to hand-rolled test runner |
|
||||
| Save file bloat (SaveStateV1 larger than ~1-2 KB/NPC) | Low | Low | Q-029 tracks compression — deferred. Profile with #342 benchmark if flagged |
|
||||
| Scope tag assignment races with eviction (#97/#98) | Low | Medium | Eviction runs after scope tag system in schedule order; schedule ordering test in #97 |
|
||||
@@ -0,0 +1,152 @@
|
||||
# Sprint 19: Persist — Server Tasks
|
||||
|
||||
**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening.
|
||||
|
||||
**Branch:** `server`
|
||||
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
|
||||
|
||||
## Carry-over from Sprint 18
|
||||
|
||||
None. Sprint 18 closed clean.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #553 | Save/load: server ECS extraction | #256 (done) |
|
||||
| #96 | State serialization system | — |
|
||||
| #97 | Timestamp-based eviction | — |
|
||||
| #98 | Scope tag system | — |
|
||||
| #200 | Test module organization | — |
|
||||
| #272 | Information boundary negative test suite | #200 |
|
||||
| #555 | Protocol version handshake: server | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-010 (determinism + info boundaries), D-020 (IPC architecture, MessagePack), D-026 (simulation tiers: Active/Background/State-saved/Ungenerated, timestamp eviction, scope tags), D-030 (three-layer test architecture), D-041 (knowledge graph data model, StableId)
|
||||
- `decisions/questions.md` — Q-029 (save file format design — Sprint 19 ships quick-and-dirty, Q-029 tracks the thorough design pass for later)
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
- **Q-029: Save file format design** — Sprint 19 uses MessagePack from `SaveStateV1`. Resolution of full versioning/migration strategy is deferred. Do not block #553 on Q-029; proceed with MessagePack format as specified.
|
||||
|
||||
## Notes
|
||||
|
||||
### #553 — Save/load: server ECS extraction
|
||||
|
||||
`server/src/simulation/save_state.rs` already defines `SaveStateV1` (done in #256). The data model is complete: tick, seed, RNG, `player_knowledge: KnowledgeGraph`, `relationship_graph: RelationshipGraph`, `npc_states: Vec<NpcSaveState>`. Roundtrip tests pass.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `save_to_file(path: &Path, world: &World) -> Result<()>` function: queries ECS for all relevant components, builds a `SaveStateV1`, calls `state.to_bytes()`, writes to disk. Per-game directory path is provided by the client via a new `IpcCommand::SaveGame { path: String }` variant.
|
||||
- A `load_from_file(path: &Path, world: &mut World) -> Result<()>` function: reads bytes, calls `SaveStateV1::from_bytes`, re-spawns entities, injects `KnowledgeGraph`, `RelationshipGraph`, and `SimulationTime` as resources, reseeds the RNG.
|
||||
- A `SaveCommand` and `LoadCommand` IPC message pair wired through `server/src/bridge/` — server receives save/load triggers from the client, executes, sends `SaveComplete`/`LoadComplete` response.
|
||||
- Format version check on load: reject files with `format_version != SAVE_FORMAT_VERSION` with a clear error.
|
||||
|
||||
Integration points: `server/src/simulation/save_state.rs` (data model), `server/src/bridge/types.rs` (new IPC commands), `server/src/bridge/local.rs` or `tcp.rs` (command dispatch), `server/src/knowledge/graph.rs` (KG re-injection), `server/src/npc/relationships.rs` (RelationshipGraph re-injection).
|
||||
|
||||
Gotcha: ECS entity IDs are generational — do not save bevy `Entity` handles. `SaveStateV1` already uses `StableId(u64)` throughout. On load, re-spawn entities and re-register `StableId -> Entity` in `EntityRegistry`.
|
||||
|
||||
### #96 — State serialization system
|
||||
|
||||
Complement to #553. Where #553 handles whole-game ECS extraction, #96 implements the per-NPC serialization primitive for tier transitions.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState` function producing the frozen struct (~1-2 KB per NPC per D-026)
|
||||
- A `deserialize_npc_from_frozen(state: &NpcSaveState, commands: &mut Commands)` that re-spawns a full NPC entity with the correct component set
|
||||
- Used by the tier system when evicting to `StateSaved`: instead of keeping ECS components live, serialize to `NpcSaveState` and despawn. On reactivation: deserialize and re-spawn.
|
||||
- Unit tests: serialize + deserialize produces an entity with identical component values
|
||||
|
||||
Existing shape: `NpcSaveState` in `save_state.rs` captures position, `SecretSeverity`, `Relationships`, stress, tolerance, contentment, and optional `KnowledgeGraph`. Verify this covers all components needed for Background/Active reconstruction. Flag any missing axis (D-024) in a code comment for follow-up.
|
||||
|
||||
### #97 — Timestamp-based eviction
|
||||
|
||||
`server/src/simulation/tier.rs` has the tier marker components (`ActiveSim`, `BackgroundSim`, `StateSaved`) and the distance-based `update_tier_markers` system. What is missing: the LRU eviction when sim-space fills up.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `LastInteractionTick(u64)` component on all NPCs, updated whenever the player interacts with or observes an NPC
|
||||
- A `SimSpacePressure` resource tracking current `ActiveSim` count vs. capacity (cap: 80 per D-026)
|
||||
- An `evict_excess_active` system: when `ActiveSim` count exceeds capacity, demote the N oldest-by-`LastInteractionTick` entities to `BackgroundSim` (or `StateSaved` if beyond background radius)
|
||||
- Uses a priority queue (BinaryHeap keyed by `LastInteractionTick`) for O(log N) eviction selection
|
||||
|
||||
Gotcha: eviction must not demote entities with active scope tags (see #98). The eviction system runs after #98's `ScopeTag` check.
|
||||
|
||||
### #98 — Scope tag system
|
||||
|
||||
Scope tags are the mechanism by which certain NPCs stay pinned to `ActiveSim` regardless of distance or LRU pressure (D-026: "neighborhood, active-quest, colleague, known-contact").
|
||||
|
||||
What this ticket must deliver:
|
||||
- A `ScopeTag` component (or enum-tagged component) with variants: `Neighborhood`, `ActiveQuest`, `Colleague`, `KnownContact`
|
||||
- A `ScopePinned` marker component: attached to any NPC carrying a `ScopeTag`, removed when no scope tags remain
|
||||
- The eviction system (#97) skips entities with `ScopePinned`
|
||||
- Scope tags are assigned by gameplay systems: `Neighborhood` from proximity at session start, `KnownContact` from `KnowledgeGraph` entries with confidence >= `KnowsOf`, `Colleague` from `RelationshipGraph` edges with `Friend` or `Colleague` kind, `ActiveQuest` reserved for future quest system
|
||||
|
||||
Integration: `server/src/simulation/tier.rs` (eviction exclusion), `server/src/knowledge/graph.rs` (KnownContact assignment trigger), `server/src/npc/relationships.rs` (Colleague assignment trigger).
|
||||
|
||||
### #200 — Test module organization
|
||||
|
||||
`server/src/test_world/` already exists with `constants.rs`, `invariants.rs`, `mod.rs`, `reset.rs`, and `rooms/`. This is the foundation.
|
||||
|
||||
What this ticket must deliver:
|
||||
- Establish the external test module pattern for the server crate: `#[cfg(test)] mod tests` in each module, plus a top-level `tests/` directory alongside `src/` for integration tests that run against the full simulation
|
||||
- Document the three-layer test architecture (D-030): Layer 1 = fixture-based serialization (fast), Layer 2 = mock subprocess protocol state machine (medium), Layer 3 = real subprocess integration (slow)
|
||||
- Create `tests/integration/mod.rs` as the entry point for Layer 3 tests
|
||||
- Ensure `cargo test` in `server/` runs all layers correctly
|
||||
- No-ops are fine for Layer 2 and 3 stubs — the important deliverable is the directory structure and entry points
|
||||
|
||||
#272 is blocked by this ticket — the information boundary tests land in the new structure.
|
||||
|
||||
### #272 — Information boundary negative test suite
|
||||
|
||||
The core asymmetric information claim of the game: entity X cannot see what entity Y knows, unless the observation system explicitly grants it.
|
||||
|
||||
What this ticket must deliver:
|
||||
- A suite of negative tests asserting that information does NOT cross boundaries:
|
||||
1. Player's `KnowledgeGraph` does not contain NPC data that was not observed (no passive leakage)
|
||||
2. `ObserverSnapshot` for the player does not include entities outside LOS (fog of perception holds)
|
||||
3. Background-tier NPC `KnowledgeGraph` is not updated by Active-tier systems (tier boundary holds)
|
||||
4. `SaveStateV1` for one NPC does not serialize another NPC's `KnowledgeGraph`
|
||||
- Uses `test_world/` for scenario setup — reuse existing helpers
|
||||
- These tests live in Layer 1 (pure unit) and Layer 2 (mock world) of D-030
|
||||
|
||||
Gotcha: "negative tests" means asserting absence. Use `assert!(kg.entities.get(&id).is_none())` patterns — not just "test passed because nothing happened."
|
||||
|
||||
### #555 — Protocol version handshake: server
|
||||
|
||||
`server/src/bridge/types.rs` defines `PROTOCOL_VERSION: u8 = 14`. The version is already included in `ObserverSnapshot` as `pub version: u8`.
|
||||
|
||||
What this ticket must deliver:
|
||||
- Verify the first `ObserverSnapshot` emitted after subprocess startup includes `version: PROTOCOL_VERSION`
|
||||
- Add a handshake phase: before normal tick loop begins, server emits a minimal `HandshakeMessage { protocol_version: PROTOCOL_VERSION }` as the very first framed message on the IPC channel
|
||||
- Client reads this message and validates before sending any `PlayerInput`
|
||||
- If the server receives a `PlayerInput` before completing handshake, log a warning and process normally (forward-compatible)
|
||||
- Integration point: `server/src/bridge/local.rs` (startup sequence), `server/src/bridge/framing.rs` (message framing)
|
||||
|
||||
Coordinate with ci team (#556) — the client-side validation is their ticket.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#555 (protocol handshake: server) → #556 (ci: protocol handshake: client)
|
||||
|
||||
#200 (test module organization) → #272 (information boundary tests)
|
||||
|
||||
#98 (scope tag system) → feeds into #97 (eviction respects scope pins)
|
||||
|
||||
#256 (done: save state data model) → #553 (server ECS extraction)
|
||||
#553 (server ECS extraction) → #554 (client: save/load UI)
|
||||
|
||||
#96 (state serialization) → feeds into #553 (used during ECS extraction)
|
||||
|
||||
Parallel starts: #555, #97, #98, #96, #200 — all unblocked week 1
|
||||
#553 starts after #96 is at review stage (needs serialize_npc_to_frozen)
|
||||
#272 starts after #200 merges
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
|
||||
```
|
||||
@@ -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
|
||||
@@ -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).*
|
||||
@@ -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<String> }` 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<StableId, EntityKnowledge>` + `BTreeMap<FactId, FactKnowledge>` 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).*
|
||||
@@ -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).*
|
||||
@@ -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).*
|
||||
@@ -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).*
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.15
|
||||
version: 0.1.19
|
||||
repository: settled-reach
|
||||
codename: commonwealth
|
||||
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.15"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -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"
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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<Res<BridgeResource>>,
|
||||
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
||||
mut running: ResMut<ServerRunning>,
|
||||
handshake: Res<HandshakeState>,
|
||||
) {
|
||||
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::<SnapshotBuffer>()
|
||||
.init_resource::<ServerRunning>()
|
||||
.init_resource::<HandshakeState>()
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
||||
.add_systems(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<crate::simulation::follow::FollowStateWire>,
|
||||
/// Examine result from Examine verb interaction (#242).
|
||||
/// Present when the player examined an NPC or object this tick.
|
||||
/// Client displays character-filtered detail text in an observation panel.
|
||||
#[serde(default)]
|
||||
pub examine_result: Option<crate::simulation::examine::ExamineResultEvent>,
|
||||
/// Character pressure state for client HUD widget (#248).
|
||||
/// Present when pressure is non-zero. Client renders tension indicator.
|
||||
#[serde(default)]
|
||||
@@ -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<PlayerKnowledgeWire>,
|
||||
/// 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<SaveLoadResultWire>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SnapshotBuffer {
|
||||
pub snapshot: Option<ObserverSnapshot>,
|
||||
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
|
||||
pub pending_save_result: Option<SaveLoadResultWire>,
|
||||
}
|
||||
|
||||
#[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::<ObserverSnapshot>(&bytes);
|
||||
assert!(result.is_err(), "HandshakeMessage must not deserialize as ObserverSnapshot");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+13
-2
@@ -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));
|
||||
|
||||
@@ -203,7 +203,13 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
let current_monologue = monologue_buffer.take();
|
||||
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
||||
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take());
|
||||
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take()).map(
|
||||
|evt| crate::bridge::types::ExamineResultWire {
|
||||
entity_id: evt.target_entity_id,
|
||||
text: evt.text,
|
||||
confidence: crate::knowledge::types::KnowledgeConfidence::KnowsDetails,
|
||||
},
|
||||
);
|
||||
let scan_events = scan_event_buffer_opt
|
||||
.as_mut()
|
||||
.map(|buf| buf.take())
|
||||
@@ -378,6 +384,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 +405,15 @@ 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,
|
||||
player_knowledge,
|
||||
save_result,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InputQueue>,
|
||||
@@ -94,6 +96,7 @@ pub fn process_player_input(
|
||||
all_positions: Query<&TilePosition>,
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<save_io::SaveLoadPending>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.init_resource::<sound::SoundEventQueue>()
|
||||
.init_resource::<spatial::NaiveSpatialIndex>()
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SaveLoadCommand>,
|
||||
}
|
||||
|
||||
/// 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::<SimulationTime>();
|
||||
(t.tick, t.tick_rate)
|
||||
};
|
||||
|
||||
// RNG seed for deterministic replay (D-010)
|
||||
let seed = world.resource::<SimRng>().seed();
|
||||
|
||||
// Player knowledge graph — the observer's epistemics at save time
|
||||
let player_knowledge = {
|
||||
let mut q = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
|
||||
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::<RelationshipGraph>().clone();
|
||||
|
||||
// Per-NPC states: collect then sort by stable_id (D-010 determinism)
|
||||
let npc_entities: Vec<Entity> = {
|
||||
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||
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<Entity> = {
|
||||
let mut q = world.query_filtered::<Entity, With<Npc>>();
|
||||
q.iter(world).collect()
|
||||
};
|
||||
for entity in npc_entities {
|
||||
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>()
|
||||
.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::<EntityRegistry>().advance_past(max_id);
|
||||
}
|
||||
|
||||
// Restore simulation resources.
|
||||
world.insert_resource(state.relationship_graph);
|
||||
{
|
||||
let mut t = world.resource_mut::<SimulationTime>();
|
||||
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::<Entity, With<PlayerCharacter>>();
|
||||
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::<SaveLoadPending>();
|
||||
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::<SnapshotBuffer>() {
|
||||
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::<EntityRegistry>();
|
||||
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<u64> = 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::<SimulationTime>();
|
||||
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::<Entity, With<Npc>>();
|
||||
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::<Entity, With<Npc>>();
|
||||
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::<EntityRegistry>();
|
||||
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::<SimulationTime>();
|
||||
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::<SimulationTime>();
|
||||
t.tick = 1;
|
||||
}
|
||||
world.insert_resource(SimRng::new(0));
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let t = world.resource::<SimulationTime>();
|
||||
assert_eq!(t.tick, 5000, "tick restored from save");
|
||||
assert_eq!(
|
||||
world.resource::<SimRng>().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::<Entity, (With<Npc>, With<BackgroundSim>)>();
|
||||
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::<SaveLoadPending>();
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
|
||||
execute_save_load(&mut world);
|
||||
|
||||
// No result written when no pending command
|
||||
let buf = world.resource::<SnapshotBuffer>();
|
||||
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::<SnapshotBuffer>();
|
||||
|
||||
let path = temp_path();
|
||||
world.insert_resource(SaveLoadPending {
|
||||
pending: Some(SaveLoadCommand::Save { path: path.clone() }),
|
||||
});
|
||||
|
||||
execute_save_load(&mut world);
|
||||
|
||||
let buf = world.resource::<SnapshotBuffer>();
|
||||
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::<SnapshotBuffer>();
|
||||
|
||||
world.insert_resource(SaveLoadPending {
|
||||
pending: Some(SaveLoadCommand::Save {
|
||||
path: PathBuf::from("/nonexistent/dir/save.msgpack"),
|
||||
}),
|
||||
});
|
||||
|
||||
execute_save_load(&mut world);
|
||||
|
||||
let buf = world.resource::<SnapshotBuffer>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Relationships>,
|
||||
@@ -99,6 +138,52 @@ pub struct NpcSaveState {
|
||||
|
||||
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
|
||||
pub knowledge_graph: Option<KnowledgeGraph>,
|
||||
|
||||
// --- 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<Want>,
|
||||
|
||||
/// Axis 2: Full secret (description + known_by list).
|
||||
/// Supersedes `secret_severity` for full reconstruction.
|
||||
#[serde(default)]
|
||||
pub secret: Option<Secret>,
|
||||
|
||||
/// Axis 5: Daily routine (phase → location schedule).
|
||||
#[serde(default)]
|
||||
pub routine: Option<DailyRoutine>,
|
||||
|
||||
/// Axis 6: Information inventory (facts this NPC carries).
|
||||
#[serde(default)]
|
||||
pub information_inventory: Option<InformationInventory>,
|
||||
|
||||
/// Supporting axis 1: Personality traits (2–3 traits, no contradictory pairs).
|
||||
#[serde(default)]
|
||||
pub personality_traits: Option<PersonalityTraits>,
|
||||
|
||||
/// Supporting axis 2: Tell system (behavioral tells tied to stress/personality).
|
||||
#[serde(default)]
|
||||
pub tell_system: Option<TellSystem>,
|
||||
|
||||
/// Supporting axis 3: Skill set (proficiency BTreeMap).
|
||||
#[serde(default)]
|
||||
pub skill_set: Option<SkillSet>,
|
||||
|
||||
/// Optional combat capability (only present for combat-trained NPCs).
|
||||
#[serde(default)]
|
||||
pub combat_capability: Option<CombatCapability>,
|
||||
|
||||
/// 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<MoodState>,
|
||||
|
||||
/// Job performance score — drifts over time, persist across tier transitions.
|
||||
#[serde(default)]
|
||||
pub job_performance: Option<JobPerformance>,
|
||||
}
|
||||
|
||||
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::<TilePosition>(entity)
|
||||
.copied()
|
||||
.unwrap_or_else(|| TilePosition::new(0, 0, 0));
|
||||
|
||||
let stable_id = world
|
||||
.get::<StableEntityId>(entity)
|
||||
.map(|s| s.0)
|
||||
.expect("NPC entity must have StableEntityId before serialization (#96)");
|
||||
|
||||
let secret = world.get::<Secret>(entity).cloned();
|
||||
let secret_severity = secret
|
||||
.as_ref()
|
||||
.map(|s| s.severity)
|
||||
.unwrap_or(SecretSeverity::Minor);
|
||||
|
||||
let (current_stress, tolerance_threshold) = world
|
||||
.get::<ToleranceThreshold>(entity)
|
||||
.map(|t| (t.current_stress, t.threshold))
|
||||
.unwrap_or((0, 50));
|
||||
|
||||
NpcSaveState {
|
||||
stable_id,
|
||||
position,
|
||||
secret_severity,
|
||||
relationships: world.get::<Relationships>(entity).cloned(),
|
||||
current_stress,
|
||||
tolerance_threshold,
|
||||
contentment: world
|
||||
.get::<Contentment>(entity)
|
||||
.map(|c| c.level)
|
||||
.unwrap_or(0),
|
||||
knowledge_graph: world.get::<KnowledgeGraph>(entity).cloned(),
|
||||
want: world.get::<Want>(entity).cloned(),
|
||||
secret,
|
||||
routine: world.get::<DailyRoutine>(entity).cloned(),
|
||||
information_inventory: world.get::<InformationInventory>(entity).cloned(),
|
||||
personality_traits: world.get::<PersonalityTraits>(entity).cloned(),
|
||||
tell_system: world.get::<TellSystem>(entity).cloned(),
|
||||
skill_set: world.get::<SkillSet>(entity).cloned(),
|
||||
combat_capability: world.get::<CombatCapability>(entity).cloned(),
|
||||
mood_state: world.get::<MoodState>(entity).cloned(),
|
||||
job_performance: world.get::<JobPerformance>(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::<StableEntityId>(original).unwrap().0;
|
||||
let rest_stable = world.get::<StableEntityId>(restored).unwrap().0;
|
||||
assert_eq!(orig_stable, rest_stable, "StableId must match");
|
||||
|
||||
// Position
|
||||
let orig_pos = world.get::<TilePosition>(original).copied().unwrap();
|
||||
let rest_pos = world.get::<TilePosition>(restored).copied().unwrap();
|
||||
assert_eq!(orig_pos, rest_pos, "position must match");
|
||||
|
||||
// Tolerance
|
||||
let orig_tol = world.get::<ToleranceThreshold>(original).cloned().unwrap();
|
||||
let rest_tol = world.get::<ToleranceThreshold>(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::<Contentment>(original).cloned().unwrap();
|
||||
let rest_con = world.get::<Contentment>(restored).cloned().unwrap();
|
||||
assert_eq!(orig_con.level, rest_con.level, "contentment must match");
|
||||
|
||||
// Want
|
||||
let orig_want = world.get::<Want>(original).cloned().unwrap();
|
||||
let rest_want = world.get::<Want>(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::<Secret>(original).cloned().unwrap();
|
||||
let rest_secret = world.get::<Secret>(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::<Npc>(entity).is_some());
|
||||
assert!(world.get::<StableEntityId>(entity).is_some());
|
||||
assert!(world.get::<ToleranceThreshold>(entity).is_some());
|
||||
assert!(world.get::<Contentment>(entity).is_some());
|
||||
assert!(world.get::<Want>(entity).is_some(), "Want defaults to Safety");
|
||||
assert!(world.get::<Secret>(entity).is_some(), "Secret built from secret_severity");
|
||||
|
||||
// Secret severity must be preserved from the legacy field
|
||||
let secret = world.get::<Secret>(entity).unwrap();
|
||||
assert_eq!(secret.severity, SecretSeverity::Moderate);
|
||||
|
||||
// Optional axes absent in frozen state → not inserted or use defaults
|
||||
assert!(world.get::<DailyRoutine>(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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScopeTagKind>,
|
||||
}
|
||||
|
||||
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::<SimSpacePressure>();
|
||||
|
||||
// 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<PlayerCharacter>>,
|
||||
rel_graph: Res<RelationshipGraph>,
|
||||
mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With<Npc>>,
|
||||
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<ScopePinned>` to skip pinned NPCs.
|
||||
pub fn sync_scope_pins(
|
||||
mut commands: Commands,
|
||||
needs_pin: Query<(Entity, &ScopeTag), Without<ScopePinned>>,
|
||||
may_need_unpin: Query<(Entity, Option<&ScopeTag>), With<ScopePinned>>,
|
||||
) {
|
||||
// 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::<ScopePinned>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<crate::simulation::time::SimulationTime>,
|
||||
vis_geo: Res<crate::perception::query::VisibilityGeometry>,
|
||||
mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With<Npc>>,
|
||||
npcs_without_tick: Query<(Entity, &TilePosition), (With<Npc>, Without<LastInteractionTick>)>,
|
||||
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<PlayerCharacter>>,
|
||||
active_npcs: Query<
|
||||
(Entity, &TilePosition, Option<&LastInteractionTick>),
|
||||
(With<ActiveSim>, With<Npc>, Without<ScopePinned>),
|
||||
>,
|
||||
active_count_query: Query<(), With<ActiveSim>>,
|
||||
mut pressure: ResMut<SimSpacePressure>,
|
||||
) {
|
||||
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<Reverse<(u64, Entity, TilePosition)>> = 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::<ActiveSim>().insert(StateSaved);
|
||||
} else {
|
||||
commands.entity(entity).remove::<ActiveSim>().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::<ActiveSim>(npc).is_none(), "demoted to Background");
|
||||
assert!(world.get::<BackgroundSim>(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::<ScopePinned>(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::<ScopePinned>(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::<ScopePinned>(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::<ScopePinned>(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::<ScopePinned>(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::<RelationshipGraph>();
|
||||
|
||||
// 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::<ScopeTag>(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::<RelationshipGraph>();
|
||||
|
||||
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::<ScopeTag>(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::<ScopeTag>(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::<RelationshipGraph>();
|
||||
|
||||
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::<ScopeTag>(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<ScopePinned>
|
||||
// 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::<Entity, (With<Npc>, Without<ScopePinned>)>();
|
||||
let unpinned_results: Vec<Entity> = 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::<ActiveSim>(npc1).is_some());
|
||||
assert!(world.get::<ActiveSim>(npc2).is_some());
|
||||
assert!(world.get::<ActiveSim>(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::<ActiveSim>(oldest).is_none(), "oldest evicted");
|
||||
assert!(world.get::<BackgroundSim>(oldest).is_some(), "oldest → Background");
|
||||
assert!(world.get::<ActiveSim>(mid).is_some(), "mid stays Active");
|
||||
assert!(world.get::<ActiveSim>(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::<ActiveSim>(pinned).is_some(), "pinned NPC stays Active");
|
||||
assert!(world.get::<ActiveSim>(unpinned).is_none(), "unpinned NPC evicted");
|
||||
assert!(world.get::<BackgroundSim>(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::<ActiveSim>(far).is_none(), "far NPC evicted");
|
||||
assert!(world.get::<StateSaved>(far).is_some(), "far NPC → StateSaved");
|
||||
assert!(world.get::<ActiveSim>(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::<ActiveSim>(no_tick).is_none(), "no-tick NPC evicted first");
|
||||
assert!(world.get::<BackgroundSim>(no_tick).is_some());
|
||||
assert!(world.get::<ActiveSim>(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::<SimSpacePressure>();
|
||||
// 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<ScopePinned> 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<ScopePinned> were absent.
|
||||
let pinned: Vec<Entity> = (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<Entity> = (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::<ActiveSim>(entity).is_some(),
|
||||
"ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)",
|
||||
i
|
||||
);
|
||||
assert!(
|
||||
world.get::<BackgroundSim>(entity).is_none(),
|
||||
"ScopePinned NPC {} must NOT be demoted to BackgroundSim",
|
||||
i
|
||||
);
|
||||
assert!(
|
||||
world.get::<StateSaved>(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::<ActiveSim>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run with: cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::poi::PoiCategory;
|
||||
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -41,12 +42,12 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
player_knowledge: None,
|
||||
save_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,12 +233,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",
|
||||
@@ -287,6 +288,151 @@ fn generate_msgpack_fixtures() {
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap());
|
||||
}
|
||||
|
||||
// === #271 fixtures: named fixtures for cross-language Layer 1 testing ===
|
||||
|
||||
// snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent
|
||||
let snapshot_minimal = fixture_snapshot(
|
||||
0,
|
||||
vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
tell_state: None,
|
||||
}],
|
||||
);
|
||||
write_fixture(
|
||||
"snapshot_minimal",
|
||||
&rmp_serde::to_vec_named(&snapshot_minimal).unwrap(),
|
||||
);
|
||||
|
||||
// snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump
|
||||
let snapshot_full = ObserverSnapshot {
|
||||
version: PROTOCOL_VERSION,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 3,
|
||||
time_of_day: 840,
|
||||
day_phase: DayPhase::Evening,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::East,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![InventoryItem {
|
||||
item_id: 7,
|
||||
name: "Forged Customs Cert".to_string(),
|
||||
slot: 0,
|
||||
}],
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
tell_state: None,
|
||||
}],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: Some(MonologueEvent {
|
||||
id: "test_monologue_001".to_string(),
|
||||
text: "Something feels off about this place.".to_string(),
|
||||
duration_seconds: 4.0,
|
||||
}),
|
||||
pending_recognitions: vec![PendingRecognitionWire {
|
||||
entity_id: 7,
|
||||
x: 12.0,
|
||||
y: 8.0,
|
||||
z: 0,
|
||||
remaining_ticks: 3,
|
||||
total_delay_ticks: 10,
|
||||
}],
|
||||
dialogue_response: Some(DialogueResponseEvent {
|
||||
line_id: "kael_d_001".to_string(),
|
||||
text: "We need to talk about the shipment.".to_string(),
|
||||
speaker_entity_id: 99,
|
||||
speaker_color_index: 2,
|
||||
speaker_name: "Kael".to_string(),
|
||||
}),
|
||||
blocked_entities: vec![5, 6],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
rng_seed: Some(0xDEADBEEF),
|
||||
poi_list: vec![PoiWire {
|
||||
poi_id: "docking_bay_7".to_string(),
|
||||
name: "Docking Bay 7".to_string(),
|
||||
x: 50,
|
||||
y: 30,
|
||||
z: 0,
|
||||
category: PoiCategory::Location,
|
||||
}],
|
||||
examine_result: Some(ExamineResultWire {
|
||||
entity_id: 42,
|
||||
text: "A smuggler, probably. The way they hold themselves.".to_string(),
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
}),
|
||||
save_result: None,
|
||||
player_knowledge: Some(PlayerKnowledgeWire {
|
||||
entities: vec![KnownEntityWire {
|
||||
entity_id: 99,
|
||||
name: "Kael".to_string(),
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: "DirectObservation".to_string(),
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Known,
|
||||
last_observed_tick: 40,
|
||||
}],
|
||||
facts: vec![KnownFactWire {
|
||||
fact_id: "poi.docking_bay_7".to_string(),
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: "DirectObservation".to_string(),
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 10,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_full",
|
||||
&rmp_serde::to_vec_named(&snapshot_full).unwrap(),
|
||||
);
|
||||
|
||||
// player_input_move: tick=1, MoveNorth
|
||||
let input_move = PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
};
|
||||
write_fixture(
|
||||
"player_input_move",
|
||||
&rmp_serde::to_vec_named(&input_move).unwrap(),
|
||||
);
|
||||
|
||||
// player_input_interact: tick=2, Interact { target: 99, verb: "Talk" }
|
||||
let input_interact = PlayerInput {
|
||||
tick: 2,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(99),
|
||||
verb: Some("Talk".to_string()),
|
||||
},
|
||||
};
|
||||
write_fixture(
|
||||
"player_input_interact",
|
||||
&rmp_serde::to_vec_named(&input_interact).unwrap(),
|
||||
);
|
||||
|
||||
// malformed: intentionally truncated bytes — tests error handling in both Rust and GDScript
|
||||
// 0x82 = fixmap with 2 entries, 0xa4 = fixstr of length 4 — incomplete map, no key/value follows
|
||||
write_fixture("malformed", &[0x82u8, 0xa4u8]);
|
||||
|
||||
// === Boundary value fixtures (#472) ===
|
||||
// 14 raw integer values at encoding format boundaries (Appendix C).
|
||||
// These are Rust-encoded MessagePack that GDScript must decode correctly.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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::<KnowledgeGraph>(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<NpcSaveState>`. Each `NpcSaveState`
|
||||
/// has its own optional `knowledge_graph: Option<KnowledgeGraph>`. 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::<KnowledgeEventQueue>();
|
||||
world.init_resource::<ContradictionDetectedQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
// 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::<EntityRegistry>();
|
||||
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::<KnowledgeEventQueue>()
|
||||
.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::<KnowledgeGraph>(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()
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! IPC round-trip latency benchmark (#342, D-020)
|
||||
//!
|
||||
//! Measures end-to-end latency from client send (write_framed) to client receive
|
||||
//! (read_framed) over the real subprocess IPC channel. Reports p50/p95/p99.
|
||||
//!
|
||||
//! Latency budget: p99 must be <= 5ms (D-020: "~1-5ms serialization latency per tick").
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo test --release --test ipc_bench -- --ignored --nocapture
|
||||
//!
|
||||
//! Output: IPC_BENCH_RESULT:{json} on a single line for tooling to parse.
|
||||
//!
|
||||
//!
|
||||
//! Spec references: D-020 (subprocess IPC, 5ms budget), D-030 (Layer 3)
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use std::io::{BufRead, BufReader, BufWriter};
|
||||
use std::net::TcpStream;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Number of warmup round-trips before timing begins.
|
||||
const WARMUP_ROUNDS: usize = 10;
|
||||
|
||||
/// Number of timed round-trips (N in the spec).
|
||||
const MEASURE_ROUNDS: usize = 100;
|
||||
|
||||
/// Latency threshold (p99 must be below this). D-020: "~1-5ms per tick".
|
||||
const THRESHOLD_MS: f64 = 5.0;
|
||||
|
||||
/// Timeout for the server to emit LISTENING:{port} on stdout.
|
||||
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Timeout per round-trip read.
|
||||
const ROUND_TRIP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn percentile(sorted: &[f64], p: f64) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let idx = ((sorted.len() - 1) as f64 * p).floor() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ipc_round_trip_latency() {
|
||||
// 1. Spawn server binary with --test-mode --port 0
|
||||
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
|
||||
let mut child = Command::new(server_bin)
|
||||
.args(["--test-mode", "--port", "0"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("failed to spawn server binary");
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout not captured");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
|
||||
// 2. Parse LISTENING:{port} from stdout
|
||||
let port = {
|
||||
let deadline = Instant::now() + LISTEN_TIMEOUT;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stdout_reader.read_line(&mut line) {
|
||||
Ok(0) => panic!("server stdout closed before LISTENING signal"),
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
|
||||
break port_str
|
||||
.parse::<u16>()
|
||||
.unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e));
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("failed to read server stdout: {}", e),
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for LISTENING signal"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Connect via TCP
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let stream = TcpStream::connect(&addr)
|
||||
.unwrap_or_else(|e| panic!("failed to connect to {}: {}", addr, e));
|
||||
stream
|
||||
.set_read_timeout(Some(ROUND_TRIP_TIMEOUT))
|
||||
.expect("set read timeout");
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
// 4. Handshake: read and validate HandshakeMessage before timing (#555/#556).
|
||||
// Server sends HandshakeMessage { protocol_version } as the very first framed message.
|
||||
let handshake_bytes = read_framed(&mut reader)
|
||||
.expect("read handshake")
|
||||
.expect("server closed before sending HandshakeMessage");
|
||||
let handshake: HandshakeMessage =
|
||||
rmp_serde::from_slice(&handshake_bytes).expect("deserialize HandshakeMessage");
|
||||
assert_eq!(
|
||||
handshake.protocol_version,
|
||||
PROTOCOL_VERSION,
|
||||
"handshake version mismatch: server={}, client={}",
|
||||
handshake.protocol_version,
|
||||
PROTOCOL_VERSION
|
||||
);
|
||||
|
||||
let make_input = |tick: u64| PlayerInput {
|
||||
tick,
|
||||
action: PlayerAction::MoveNorth,
|
||||
};
|
||||
|
||||
let mut round_trip_ms: Vec<f64> = Vec::with_capacity(WARMUP_ROUNDS + MEASURE_ROUNDS);
|
||||
|
||||
// 5. Warmup rounds (not timed)
|
||||
for tick in 0..WARMUP_ROUNDS as u64 {
|
||||
let payload =
|
||||
rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput");
|
||||
write_framed(&mut writer, &payload).expect("send warmup input");
|
||||
let _ = read_framed(&mut reader)
|
||||
.expect("read warmup snapshot")
|
||||
.expect("server closed during warmup");
|
||||
}
|
||||
|
||||
// 6. Timed measurement rounds
|
||||
for tick in WARMUP_ROUNDS as u64..(WARMUP_ROUNDS + MEASURE_ROUNDS) as u64 {
|
||||
let payload =
|
||||
rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput");
|
||||
|
||||
let t_send = Instant::now();
|
||||
write_framed(&mut writer, &payload).expect("send timed input");
|
||||
let response = read_framed(&mut reader)
|
||||
.expect("read timed snapshot")
|
||||
.expect("server closed during measurement");
|
||||
let elapsed_ms = t_send.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Verify we received a valid snapshot (not just noise)
|
||||
let _snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
|
||||
|
||||
round_trip_ms.push(elapsed_ms);
|
||||
}
|
||||
|
||||
// 7. Clean up
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
let exit_deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if Instant::now() > exit_deadline {
|
||||
child.kill().ok();
|
||||
child.wait().ok();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => {
|
||||
child.kill().ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Compute percentiles
|
||||
let mut sorted = round_trip_ms.clone();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
let p50 = percentile(&sorted, 0.50);
|
||||
let p95 = percentile(&sorted, 0.95);
|
||||
let p99 = percentile(&sorted, 0.99);
|
||||
let passed = p99 <= THRESHOLD_MS;
|
||||
|
||||
let result = serde_json::json!({
|
||||
"p50_ms": (p50 * 100.0).round() / 100.0,
|
||||
"p95_ms": (p95 * 100.0).round() / 100.0,
|
||||
"p99_ms": (p99 * 100.0).round() / 100.0,
|
||||
"threshold_ms": THRESHOLD_MS,
|
||||
"passed": passed,
|
||||
"rounds": MEASURE_ROUNDS,
|
||||
});
|
||||
|
||||
println!(
|
||||
"IPC_BENCH_RESULT:{}",
|
||||
serde_json::to_string(&result).unwrap()
|
||||
);
|
||||
|
||||
// Fail the test if we exceed the latency budget
|
||||
assert!(
|
||||
passed,
|
||||
"IPC latency budget exceeded: p99={:.2}ms > threshold={}ms",
|
||||
p99, THRESHOLD_MS
|
||||
);
|
||||
}
|
||||
+16
-4
@@ -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);
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
examine_result: None,
|
||||
character_pressure: None,
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
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 {
|
||||
@@ -174,13 +180,15 @@ fn all_fixtures_deserialize() {
|
||||
} else if name.starts_with("input_batch") {
|
||||
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
|
||||
} else if name.starts_with("input") {
|
||||
} else if name.starts_with("input") || name.starts_with("player_input") {
|
||||
rmp_serde::from_slice::<PlayerInput>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
|
||||
} else if name.starts_with("boundary_raw") {
|
||||
// Raw integer boundary fixtures (#472): single u64 values
|
||||
rmp_serde::from_slice::<u64>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e));
|
||||
} else if name == "malformed" {
|
||||
// Intentionally truncated — skip deserialization check, error handling tested elsewhere
|
||||
} else {
|
||||
panic!(
|
||||
"unknown fixture naming convention: {} — add a deserialization branch for this prefix",
|
||||
@@ -280,12 +288,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 +348,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 +392,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");
|
||||
@@ -1394,7 +1402,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
|
||||
// are all `#[serde(default)]` — they must default to None/empty when absent.
|
||||
let minimal_json = serde_json::json!({
|
||||
"version": 14,
|
||||
"version": 13,
|
||||
"tick": 42,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
@@ -1433,7 +1441,7 @@ 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)
|
||||
// Version matches what was in the wire
|
||||
assert_eq!(decoded.version, 13);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
@@ -1642,3 +1650,142 @@ fn nearby_interaction_object_type_roundtrip() {
|
||||
Some(ObjectType::Container)
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// #271: Named fixture validation tests (D-030 Layer 1)
|
||||
//
|
||||
// These tests read the committed .msgpack files and assert specific field
|
||||
// values. They serve as the Rust side of cross-language verification — the
|
||||
// same fixtures are decoded by client/tests/test_ipc_fixtures.gd.
|
||||
// ============================================================
|
||||
|
||||
fn read_named_fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!("../client/tests/fixtures/msgpack/{}.msgpack", name);
|
||||
fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture '{}': {}", name, e))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_snapshot_minimal_fields() {
|
||||
let bytes = read_named_fixture("snapshot_minimal");
|
||||
let snap: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal");
|
||||
|
||||
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
|
||||
assert_eq!(snap.tick, 0, "tick should be 0");
|
||||
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
|
||||
assert_eq!(snap.entities[0].entity_id, 1);
|
||||
assert!(
|
||||
matches!(snap.entities[0].kind, EntityKind::Player),
|
||||
"entity should be Player kind"
|
||||
);
|
||||
assert!(snap.current_monologue.is_none(), "no monologue in minimal");
|
||||
assert!(snap.dialogue_response.is_none(), "no dialogue in minimal");
|
||||
assert!(snap.player_inventory.is_empty(), "no inventory in minimal");
|
||||
assert!(snap.poi_list.is_empty(), "no POIs in minimal");
|
||||
assert!(snap.player_knowledge.is_none(), "no KG in minimal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_snapshot_full_fields() {
|
||||
let bytes = read_named_fixture("snapshot_full");
|
||||
let snap: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full");
|
||||
|
||||
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
|
||||
assert_eq!(snap.tick, 42, "tick should be 42");
|
||||
|
||||
// Monologue
|
||||
let monologue = snap.current_monologue.as_ref().expect("monologue absent");
|
||||
assert_eq!(monologue.id, "test_monologue_001");
|
||||
assert_eq!(
|
||||
monologue.text,
|
||||
"Something feels off about this place."
|
||||
);
|
||||
|
||||
// Dialogue
|
||||
let dialogue = snap.dialogue_response.as_ref().expect("dialogue absent");
|
||||
assert_eq!(dialogue.speaker_entity_id, 99);
|
||||
assert_eq!(dialogue.speaker_name, "Kael");
|
||||
|
||||
// Inventory
|
||||
assert_eq!(snap.player_inventory.len(), 1);
|
||||
assert_eq!(snap.player_inventory[0].name, "Forged Customs Cert");
|
||||
|
||||
// POIs
|
||||
assert_eq!(snap.poi_list.len(), 1);
|
||||
assert_eq!(snap.poi_list[0].poi_id, "docking_bay_7");
|
||||
|
||||
// Examine result
|
||||
let examine = snap.examine_result.as_ref().expect("examine_result absent");
|
||||
assert_eq!(examine.entity_id, 42);
|
||||
|
||||
// Player knowledge
|
||||
let kg = snap.player_knowledge.as_ref().expect("player_knowledge absent");
|
||||
assert_eq!(kg.entities.len(), 1);
|
||||
assert_eq!(kg.entities[0].name, "Kael");
|
||||
assert_eq!(kg.facts.len(), 1);
|
||||
assert_eq!(kg.facts[0].fact_id, "poi.docking_bay_7");
|
||||
|
||||
// RNG seed
|
||||
assert_eq!(snap.rng_seed, Some(0xDEADBEEF));
|
||||
|
||||
// Pending recognitions
|
||||
assert_eq!(snap.pending_recognitions.len(), 1);
|
||||
assert_eq!(snap.pending_recognitions[0].entity_id, 7);
|
||||
|
||||
// Blocked entities
|
||||
assert_eq!(snap.blocked_entities, vec![5u64, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_player_input_move_fields() {
|
||||
let bytes = read_named_fixture("player_input_move");
|
||||
let input: PlayerInput =
|
||||
rmp_serde::from_slice(&bytes).expect("deserialize player_input_move");
|
||||
|
||||
assert_eq!(input.tick, 1, "tick should be 1");
|
||||
assert!(
|
||||
matches!(input.action, PlayerAction::MoveNorth),
|
||||
"action should be MoveNorth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_player_input_interact_fields() {
|
||||
let bytes = read_named_fixture("player_input_interact");
|
||||
let input: PlayerInput =
|
||||
rmp_serde::from_slice(&bytes).expect("deserialize player_input_interact");
|
||||
|
||||
assert_eq!(input.tick, 2, "tick should be 2");
|
||||
match &input.action {
|
||||
PlayerAction::Interact {
|
||||
target_entity_id,
|
||||
verb,
|
||||
} => {
|
||||
assert_eq!(*target_entity_id, Some(99u64), "target_entity_id should be Some(99)");
|
||||
assert_eq!(
|
||||
verb.as_deref(),
|
||||
Some("Talk"),
|
||||
"verb should be Some(\"Talk\")"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Interact, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_malformed_fails_deserialization() {
|
||||
let bytes = read_named_fixture("malformed");
|
||||
// Intentionally truncated — must NOT deserialize as ObserverSnapshot
|
||||
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"malformed fixture should fail to deserialize as ObserverSnapshot"
|
||||
);
|
||||
// Also must NOT deserialize as PlayerInput
|
||||
let result2 = rmp_serde::from_slice::<PlayerInput>(&bytes);
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"malformed fixture should fail to deserialize as PlayerInput"
|
||||
);
|
||||
}
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-all: Run all test suites in order (D-030)
|
||||
# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration.
|
||||
# Exit: 0 = all suites pass, non-zero = any suite failed
|
||||
# Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]}
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PASS_ARGS=()
|
||||
[[ -n "$FILTER" ]] && PASS_ARGS+=(--filter "$FILTER")
|
||||
|
||||
SUITES=(
|
||||
run-rust
|
||||
run-godot
|
||||
run-ipc-fixtures
|
||||
run-ipc-protocol
|
||||
run-ipc-integration
|
||||
)
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
|
||||
OVERALL_TOTAL=0
|
||||
OVERALL_PASSED=0
|
||||
OVERALL_FAILED=0
|
||||
OVERALL_EXIT=0
|
||||
SUITE_RESULTS=""
|
||||
|
||||
for suite in "${SUITES[@]}"; do
|
||||
script="$SCRIPT_DIR/$suite"
|
||||
if [[ ! -x "$script" ]]; then
|
||||
echo "Warning: $script not found or not executable — skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
SUITE_OUT=$(mktemp)
|
||||
set +e
|
||||
"$script" "${PASS_ARGS[@]}" >"$SUITE_OUT"
|
||||
SUITE_EXIT=$?
|
||||
set -e
|
||||
|
||||
SUITE_JSON=$(cat "$SUITE_OUT")
|
||||
rm -f "$SUITE_OUT"
|
||||
|
||||
# Accumulate totals from the suite's JSON output
|
||||
S_TOTAL=$(echo "$SUITE_JSON" | grep -oE '"total":[0-9]+' | grep -oE '[0-9]+' || echo 0)
|
||||
S_PASSED=$(echo "$SUITE_JSON" | grep -oE '"passed":[0-9]+' | grep -oE '[0-9]+' || echo 0)
|
||||
S_FAILED=$(echo "$SUITE_JSON" | grep -oE '"failed":[0-9]+' | grep -oE '[0-9]+' || echo 0)
|
||||
|
||||
OVERALL_TOTAL=$(( OVERALL_TOTAL + ${S_TOTAL:-0} ))
|
||||
OVERALL_PASSED=$(( OVERALL_PASSED + ${S_PASSED:-0} ))
|
||||
OVERALL_FAILED=$(( OVERALL_FAILED + ${S_FAILED:-0} ))
|
||||
[[ $SUITE_EXIT -ne 0 ]] && OVERALL_EXIT=1
|
||||
|
||||
# Build suites array for JSON
|
||||
if [[ -n "$SUITE_RESULTS" ]]; then
|
||||
SUITE_RESULTS="$SUITE_RESULTS,$SUITE_JSON"
|
||||
else
|
||||
SUITE_RESULTS="$SUITE_JSON"
|
||||
fi
|
||||
done
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
printf '{"suite":"all","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"suites":[%s]}\n' \
|
||||
"$OVERALL_TOTAL" "$OVERALL_PASSED" "$OVERALL_FAILED" "$DURATION_MS" "$SUITE_RESULTS"
|
||||
exit $OVERALL_EXIT
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-godot: Run Godot client test suite via gdUnit4 (D-030)
|
||||
# Exit: 0 = all pass, non-zero = failure
|
||||
# Stdout: {"suite":"godot","total":N,"passed":N,"failed":N,"duration_ms":N}
|
||||
#
|
||||
# --filter: accepts a test filename stem (e.g. "test_protocol" → runs test_protocol.gd only)
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "")
|
||||
if [[ -z "$GODOT" ]]; then
|
||||
printf '{"suite":"godot","total":0,"passed":0,"failed":0,"duration_ms":0,"error":"godot not found in PATH"}\n'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the test target: directory or specific file
|
||||
if [[ -n "$FILTER" ]]; then
|
||||
# Support bare name (test_protocol) or full path (test_protocol.gd)
|
||||
if [[ "$FILTER" == res://* ]]; then
|
||||
TEST_TARGET="$FILTER"
|
||||
elif [[ "$FILTER" == *.gd ]]; then
|
||||
TEST_TARGET="res://tests/$FILTER"
|
||||
else
|
||||
TEST_TARGET="res://tests/${FILTER}.gd"
|
||||
fi
|
||||
else
|
||||
TEST_TARGET="res://tests/"
|
||||
fi
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
TMPOUT=$(mktemp)
|
||||
|
||||
set +e
|
||||
"$GODOT" --headless --path "$REPO_ROOT/client" \
|
||||
-s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \
|
||||
--ignoreHeadlessMode \
|
||||
-c \
|
||||
-a "$TEST_TARGET" \
|
||||
2>&1 | tee "$TMPOUT" >&2
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
_extract_num() {
|
||||
local haystack="$1" pattern="$2"
|
||||
echo "$haystack" | grep -oiE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0
|
||||
}
|
||||
|
||||
# gdUnit4 outputs per-suite statistics: "N test cases | X errors | Y failures | ..."
|
||||
# and a summary: "Executed test cases : (X/N)" or "Executed test cases : (X/N), Z skipped"
|
||||
TOTAL=0; PASSED=0; FAILED=0
|
||||
|
||||
# Sum errors + failures across all suite statistics lines
|
||||
STATS_LINES=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$TMPOUT" || true)
|
||||
if [[ -n "$STATS_LINES" ]]; then
|
||||
TOTAL=$(echo "$STATS_LINES" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
ERRORS=$(echo "$STATS_LINES" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
FAILURES=$(echo "$STATS_LINES" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
FAILED=$(( ${ERRORS:-0} + ${FAILURES:-0} ))
|
||||
PASSED=$(( TOTAL - FAILED ))
|
||||
fi
|
||||
|
||||
# Fallback: parse "Executed test cases : (X/N)" for total if stats parse failed
|
||||
if [[ "$TOTAL" -eq 0 ]]; then
|
||||
EXEC_LINE=$(grep -oE "Executed test cases : \([0-9]+/[0-9]+\)" "$TMPOUT" | tail -1 || true)
|
||||
if [[ -n "$EXEC_LINE" ]]; then
|
||||
TOTAL=$(echo "$EXEC_LINE" | grep -oE '/[0-9]+\)' | grep -oE '[0-9]+')
|
||||
PASSED=$(echo "$EXEC_LINE" | grep -oE '\([0-9]+/' | grep -oE '[0-9]+')
|
||||
FAILED=$(( TOTAL - PASSED ))
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$TMPOUT"
|
||||
printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
|
||||
"${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS"
|
||||
exit $EXIT_CODE
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-ipc-benchmark: IPC round-trip latency benchmark (#342, D-020)
|
||||
#
|
||||
# Runs server/tests/ipc_bench.rs via `cargo test --release --test ipc_bench`.
|
||||
# Parses IPC_BENCH_RESULT:{json} from output and outputs the result JSON.
|
||||
#
|
||||
# Latency budget: p99 <= 5ms (D-020: "~1-5ms serialization latency per tick").
|
||||
#
|
||||
# NOTE (#342): Handshake step is stubbed in ipc_bench.rs pending #555 (server
|
||||
# protocol handshake) and #556 (client handshake). Full clean timing requires
|
||||
# a working handshake before the measurement loop starts.
|
||||
#
|
||||
# Exit: 0 = benchmark passed (p99 within threshold), non-zero = failure
|
||||
# Stdout: {"p50_ms":N,"p95_ms":N,"p99_ms":N,"threshold_ms":5,"passed":true,"rounds":100}
|
||||
set -euo pipefail
|
||||
|
||||
THRESHOLD_MS=5
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) shift 2 ;; # ignored — benchmark has no test filter
|
||||
--filter=*) shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
TMPOUT=$(mktemp)
|
||||
|
||||
set +e
|
||||
cd "$REPO_ROOT/server" && \
|
||||
cargo test --release --test ipc_bench -- --ignored --nocapture 2>&1 | tee "$TMPOUT" >&2
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
# Extract IPC_BENCH_RESULT:{json} line from output
|
||||
RESULT_LINE=$(grep "^IPC_BENCH_RESULT:" "$TMPOUT" | tail -1 || true)
|
||||
rm -f "$TMPOUT"
|
||||
|
||||
if [[ -n "$RESULT_LINE" ]]; then
|
||||
# Strip the prefix and output the JSON
|
||||
echo "${RESULT_LINE#IPC_BENCH_RESULT:}"
|
||||
else
|
||||
# No result line — test failed to produce output
|
||||
printf '{"p50_ms":0,"p95_ms":0,"p99_ms":0,"threshold_ms":%d,"passed":false,"error":"no benchmark output — server binary may not be built (run make build-server)"}\n' \
|
||||
"$THRESHOLD_MS"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-ipc-fixtures: Layer 1 IPC fixture tests (D-030)
|
||||
# Runs Rust serialization round-trip tests + GDScript fixture validation.
|
||||
# GDScript side is skipped if client/tests/test_ipc_fixtures.gd doesn't exist yet (#271).
|
||||
# Exit: 0 = all pass, non-zero = any failure
|
||||
# Stdout: {"suite":"ipc-fixtures","total":N,"passed":N,"failed":N,"duration_ms":N}
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
_extract_num() {
|
||||
local haystack="$1" pattern="$2"
|
||||
echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0
|
||||
}
|
||||
|
||||
_parse_nextest_summary() {
|
||||
local tmpout="$1"
|
||||
local summary total passed failed
|
||||
summary=$(grep -E "^\s*(Summary|Finished)" "$tmpout" | tail -1 || true)
|
||||
if [[ -n "$summary" ]]; then
|
||||
total=$(_extract_num "$summary" "tests? run")
|
||||
passed=$(_extract_num "$summary" "passed")
|
||||
failed=$(_extract_num "$summary" "failed")
|
||||
else
|
||||
total=0; passed=0; failed=0
|
||||
fi
|
||||
echo "$total $passed $failed"
|
||||
}
|
||||
|
||||
# --- Layer 1a: Rust serialization tests ---
|
||||
START_MS=$(date +%s%3N)
|
||||
|
||||
cd "$REPO_ROOT/server"
|
||||
TMPOUT=$(mktemp)
|
||||
NEXTEST_ARGS=(nextest run --color never --test serialization)
|
||||
[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})")
|
||||
|
||||
set +e
|
||||
cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2
|
||||
RUST_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
read -r RUST_TOTAL RUST_PASSED RUST_FAILED < <(_parse_nextest_summary "$TMPOUT")
|
||||
rm -f "$TMPOUT"
|
||||
|
||||
# --- Layer 1b: GDScript fixture tests (optional until #271 lands) ---
|
||||
GDS_FIXTURE="$REPO_ROOT/client/tests/test_ipc_fixtures.gd"
|
||||
GDS_TOTAL=0; GDS_PASSED=0; GDS_FAILED=0; GDS_EXIT=0
|
||||
|
||||
if [[ -f "$GDS_FIXTURE" ]]; then
|
||||
GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "")
|
||||
if [[ -z "$GODOT" ]]; then
|
||||
echo "Warning: test_ipc_fixtures.gd found but godot not in PATH — skipping GDScript layer" >&2
|
||||
else
|
||||
GDTMP=$(mktemp)
|
||||
set +e
|
||||
"$GODOT" --headless --path "$REPO_ROOT/client" \
|
||||
-s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \
|
||||
--ignoreHeadlessMode -c \
|
||||
-a res://tests/test_ipc_fixtures.gd \
|
||||
2>&1 | tee "$GDTMP" >&2
|
||||
GDS_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
STATS=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$GDTMP" || true)
|
||||
if [[ -n "$STATS" ]]; then
|
||||
GDS_TOTAL=$(echo "$STATS" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
ERRS=$(echo "$STATS" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
FAILS=$(echo "$STATS" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}')
|
||||
GDS_FAILED=$(( ${ERRS:-0} + ${FAILS:-0} ))
|
||||
GDS_PASSED=$(( GDS_TOTAL - GDS_FAILED ))
|
||||
fi
|
||||
rm -f "$GDTMP"
|
||||
fi
|
||||
fi
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
TOTAL=$(( RUST_TOTAL + GDS_TOTAL ))
|
||||
PASSED=$(( RUST_PASSED + GDS_PASSED ))
|
||||
FAILED=$(( RUST_FAILED + GDS_FAILED ))
|
||||
|
||||
# Overall exit: fail if either side failed
|
||||
EXIT_CODE=$(( RUST_EXIT != 0 || GDS_EXIT != 0 ? 1 : 0 ))
|
||||
|
||||
printf '{"suite":"ipc-fixtures","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
|
||||
"$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS"
|
||||
exit $EXIT_CODE
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-ipc-integration: Layer 3 real-subprocess integration tests (D-030)
|
||||
# Spawns the server binary as a real child process, runs IPC round-trip.
|
||||
# Also invokes tests/run-ipc-benchmark when that script exists (#342).
|
||||
# Exit: 0 = all pass, non-zero = any failure
|
||||
# Stdout: {"suite":"ipc-integration","total":N,"passed":N,"failed":N,"duration_ms":N}
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT/server"
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
TMPOUT=$(mktemp)
|
||||
|
||||
NEXTEST_ARGS=(nextest run --color never --test layer3)
|
||||
[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})")
|
||||
|
||||
set +e
|
||||
cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2
|
||||
LAYER3_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
_extract_num() {
|
||||
local haystack="$1" pattern="$2"
|
||||
echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0
|
||||
}
|
||||
|
||||
SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true)
|
||||
TOTAL=0; PASSED=0; FAILED=0
|
||||
if [[ -n "$SUMMARY" ]]; then
|
||||
TOTAL=$(_extract_num "$SUMMARY" "tests? run")
|
||||
PASSED=$(_extract_num "$SUMMARY" "passed")
|
||||
FAILED=$(_extract_num "$SUMMARY" "failed")
|
||||
fi
|
||||
rm -f "$TMPOUT"
|
||||
|
||||
# Run IPC benchmark if it exists (#342 — requires handshake from #555/#556)
|
||||
BENCH_SCRIPT="$REPO_ROOT/tests/run-ipc-benchmark"
|
||||
BENCH_EXIT=0
|
||||
if [[ -x "$BENCH_SCRIPT" ]]; then
|
||||
BENCH_ARGS=()
|
||||
[[ -n "$FILTER" ]] && BENCH_ARGS+=(--filter "$FILTER")
|
||||
set +e
|
||||
"$BENCH_SCRIPT" "${BENCH_ARGS[@]}" >&2
|
||||
BENCH_EXIT=$?
|
||||
set -e
|
||||
if [[ $BENCH_EXIT -ne 0 ]]; then
|
||||
FAILED=$(( FAILED + 1 ))
|
||||
TOTAL=$(( TOTAL + 1 ))
|
||||
else
|
||||
PASSED=$(( PASSED + 1 ))
|
||||
TOTAL=$(( TOTAL + 1 ))
|
||||
fi
|
||||
fi
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
EXIT_CODE=$(( LAYER3_EXIT != 0 || BENCH_EXIT != 0 ? 1 : 0 ))
|
||||
|
||||
printf '{"suite":"ipc-integration","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
|
||||
"$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS"
|
||||
exit $EXIT_CODE
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-ipc-protocol: Layer 2 mock IPC protocol tests (D-030)
|
||||
# Runs LocalBridge Unix-socket round-trip tests (no real subprocess).
|
||||
# Exit: 0 = all pass, non-zero = failure
|
||||
# Stdout: {"suite":"ipc-protocol","total":N,"passed":N,"failed":N,"duration_ms":N}
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT/server"
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
TMPOUT=$(mktemp)
|
||||
|
||||
NEXTEST_ARGS=(nextest run --color never --test bridge_ipc)
|
||||
[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})")
|
||||
|
||||
set +e
|
||||
cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
_extract_num() {
|
||||
local haystack="$1" pattern="$2"
|
||||
echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0
|
||||
}
|
||||
|
||||
SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true)
|
||||
TOTAL=0; PASSED=0; FAILED=0
|
||||
if [[ -n "$SUMMARY" ]]; then
|
||||
TOTAL=$(_extract_num "$SUMMARY" "tests? run")
|
||||
PASSED=$(_extract_num "$SUMMARY" "passed")
|
||||
FAILED=$(_extract_num "$SUMMARY" "failed")
|
||||
fi
|
||||
|
||||
rm -f "$TMPOUT"
|
||||
printf '{"suite":"ipc-protocol","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
|
||||
"${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS"
|
||||
exit $EXIT_CODE
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/run-rust: Run Rust test suite via cargo nextest (D-030)
|
||||
# Exit: 0 = all pass, non-zero = failure
|
||||
# Stdout: {"suite":"rust","total":N,"passed":N,"failed":N,"duration_ms":N}
|
||||
set -euo pipefail
|
||||
|
||||
FILTER=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter) FILTER="${2:-}"; shift 2 ;;
|
||||
--filter=*) FILTER="${1#--filter=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT/server"
|
||||
|
||||
START_MS=$(date +%s%3N)
|
||||
TMPOUT=$(mktemp)
|
||||
|
||||
NEXTEST_ARGS=(nextest run --color never)
|
||||
if [[ -n "$FILTER" ]]; then
|
||||
NEXTEST_ARGS+=(-E "test(~${FILTER})")
|
||||
fi
|
||||
|
||||
set +e
|
||||
cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
END_MS=$(date +%s%3N)
|
||||
DURATION_MS=$((END_MS - START_MS))
|
||||
|
||||
# Parse nextest summary: " Summary [ 0.123s] N tests run: X passed[, Y failed], Z skipped"
|
||||
# (older nextest uses "Finished", newer uses "Summary" — match both)
|
||||
SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true)
|
||||
|
||||
_extract_num() {
|
||||
local haystack="$1" pattern="$2"
|
||||
echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0
|
||||
}
|
||||
|
||||
TOTAL=0; PASSED=0; FAILED=0
|
||||
if [[ -n "$SUMMARY" ]]; then
|
||||
TOTAL=$(_extract_num "$SUMMARY" "tests? run")
|
||||
PASSED=$(_extract_num "$SUMMARY" "passed")
|
||||
FAILED=$(_extract_num "$SUMMARY" "failed")
|
||||
fi
|
||||
|
||||
rm -f "$TMPOUT"
|
||||
printf '{"suite":"rust","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
|
||||
"${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS"
|
||||
exit $EXIT_CODE
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit check: detect duplicate decision IDs across decisions/*.md files.
|
||||
# Fails if the same D-NNN, Q-NNN, or R-NNN appears in more than one file.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Use the decisions_sync.py check-dupes command
|
||||
RESULT=$(python3 "$REPO_ROOT/db/connectors/decisions_sync.py" check-dupes 2>&1)
|
||||
|
||||
# Parse the JSON result
|
||||
DUPES=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('duplicates',0))" 2>/dev/null || echo "0")
|
||||
TOTAL=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total_ids',0))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$DUPES" -gt 0 ]; then
|
||||
echo "check-decision-ids: FAILED — $DUPES duplicate ID(s) found"
|
||||
echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
for detail in d.get('details', []):
|
||||
print(f' {detail}')
|
||||
" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check-decision-ids: OK — $TOTAL unique IDs, no duplicates"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user