Compare commits
65
Commits
cc898d60c7
...
v0.1.19
@@ -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/
|
||||
|
||||
@@ -6,12 +6,61 @@ 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)
|
||||
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
|
||||
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
|
||||
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
@@ -26,6 +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-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)
|
||||
@@ -40,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."
|
||||
|
||||
@@ -125,6 +128,17 @@ knowledge_panel:
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||
confidence_direct: "Confirmed"
|
||||
confidence_knowsdetails: "Detailed"
|
||||
confidence_knowsof: "Known"
|
||||
confidence_suspects: "Unconfirmed"
|
||||
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||
source_directobservation: "Observed"
|
||||
source_toldby: "Told"
|
||||
source_heard: "Overheard"
|
||||
source_inferred: "Inferred"
|
||||
source_background: "Prior"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
@@ -159,6 +173,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
|
||||
+17
-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]
|
||||
|
||||
@@ -125,11 +126,26 @@ debug_overlay={
|
||||
"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
open_journal={
|
||||
"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":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"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":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]
|
||||
|
||||
|
||||
+12
-3
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=24 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -23,6 +23,8 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -134,6 +136,15 @@ layer = 10
|
||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||
|
||||
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
|
||||
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
|
||||
|
||||
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
|
||||
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
|
||||
|
||||
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
@@ -141,8 +152,6 @@ layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -70,6 +85,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
|
||||
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
|
||||
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
|
||||
# Populated from snapshot "poi_list" field — only updated when field present.
|
||||
var discovered_pois: Array = []
|
||||
|
||||
# v14 fields (#174, #242): Character-filtered examine result.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var current_examine_result: Variant = null
|
||||
|
||||
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
@@ -240,6 +270,31 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||
# Only update if the field is present — absence means "no change since last tick".
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
current_examine_result = snapshot.examine_result
|
||||
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:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
||||
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
||||
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
||||
|
||||
@@ -19,8 +19,11 @@ enum Action {
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
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] = []
|
||||
@@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("open_journal"):
|
||||
action = Action.OPEN_JOURNAL
|
||||
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"
|
||||
|
||||
@@ -15,6 +15,9 @@ extends Node2D
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@@ -99,6 +102,8 @@ func _process(delta: float) -> void:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap and minimap.has_method("set_insert_active"):
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
@@ -136,6 +141,10 @@ func _process(delta: float) -> void:
|
||||
if time_display and time_display.has_method("update_from_state"):
|
||||
time_display.update_from_state()
|
||||
|
||||
# #264: Update journal panel — auto-close on dialogue, refresh if open
|
||||
if journal_panel and journal_panel.has_method("update_from_state"):
|
||||
journal_panel.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
@@ -150,6 +159,9 @@ func _process(delta: float) -> void:
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# #174: Show examine result if server sent one this tick (#242)
|
||||
_consume_examine_result()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -161,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.
|
||||
@@ -184,6 +199,10 @@ func _process(delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #264: J — client-only, toggle knowledge journal panel
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
@@ -318,6 +337,9 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
@@ -362,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({
|
||||
@@ -456,6 +499,33 @@ func _teleport_transition() -> void:
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||
func _consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||
func _toggle_journal() -> void:
|
||||
if not journal_panel:
|
||||
return
|
||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
return
|
||||
if journal_panel.has_method("toggle"):
|
||||
journal_panel.toggle()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
|
||||
@@ -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 = 13
|
||||
const PROTOCOL_VERSION: int = 15
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -206,6 +206,81 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# 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:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var examine_result: Variant = null
|
||||
var raw_examine: Variant = raw.get("examine_result")
|
||||
if raw_examine is Dictionary and raw_examine.has("text"):
|
||||
examine_result = {
|
||||
"entity_id": int(raw_examine.get("entity_id", 0)),
|
||||
"text": str(raw_examine["text"]),
|
||||
"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}]}
|
||||
var player_knowledge: Variant = null
|
||||
var raw_pk: Variant = raw.get("player_knowledge")
|
||||
if raw_pk is Dictionary:
|
||||
var kg_entities: Array = []
|
||||
var raw_kg_entities: Variant = raw_pk.get("entities")
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -223,6 +298,10 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
"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,598 @@
|
||||
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
|
||||
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
|
||||
## D-064 (walk-away), D-078 (overheard log)
|
||||
##
|
||||
## Test plan from joint.md:
|
||||
## "Manual: examine result appears as overlay, auto-dismisses.
|
||||
## Dialogue options confirmed: no locked/grayed options visible.
|
||||
## Confrontation option in italic voice."
|
||||
##
|
||||
## Unit-testable coverage here:
|
||||
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
|
||||
## - D-063: confrontation beat duration, signal, dim alpha
|
||||
## - D-064: walk-away fires dialogue_dismissed signal
|
||||
## - GameState: current_dialogue parsing
|
||||
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
|
||||
## - BBCode: escape contract (Hoshe #2 regression guard)
|
||||
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
|
||||
class_name TestDialogueSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
|
||||
var opts: Array = []
|
||||
for i in range(texts.size()):
|
||||
var opt: Dictionary = {
|
||||
"text": texts[i],
|
||||
"response_id": "r%d" % i,
|
||||
"priority": i,
|
||||
}
|
||||
if confrontation_flags.size() > i:
|
||||
opt["confrontation"] = confrontation_flags[i]
|
||||
opts.append(opt)
|
||||
return opts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-062: No locked/grayed options
|
||||
## Per D-062: locked options are INVISIBLE — not shown at all.
|
||||
## The client renders all received options as active, clickable labels.
|
||||
## Server responsibility: omit locked options from the array.
|
||||
## Test verifies: no disabled/locked styling is applied to any rendered option.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d062_rendered_options_have_no_disabled_state() -> void:
|
||||
## D-062: All options from server render as active controls.
|
||||
## No option should have mouse_filter=IGNORE (which would indicate disabled).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
|
||||
|
||||
for label in labels:
|
||||
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
|
||||
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
|
||||
assert_int(label.mouse_filter).override_failure_message(
|
||||
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
|
||||
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_no_lock_icon_children_on_options() -> void:
|
||||
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
|
||||
## Any child node on an option label would indicate a locked-option indicator.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).is_greater(0)
|
||||
|
||||
for label in labels:
|
||||
assert_int(label.get_child_count()).override_failure_message(
|
||||
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
|
||||
).is_equal(0)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_max_options_constant_is_three() -> void:
|
||||
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
|
||||
## are shown — server must omit locked ones, client only truncates to 3.
|
||||
## Verify MAX_OPTIONS constant is locked at 3.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box.MAX_OPTIONS).override_failure_message(
|
||||
"MAX_OPTIONS must be 3 per D-061 spec"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_server_sends_four_options_only_three_render() -> void:
|
||||
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
|
||||
## only top 3 by priority render. No 4th option appears.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var opts := _make_options(["A", "B", "C", "D"])
|
||||
# Assign explicit priorities so sort is deterministic
|
||||
for i in range(opts.size()):
|
||||
opts[i]["priority"] = i
|
||||
box.show_dialogue("NPC", "Speech.", opts)
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
assert_int(options_container.get_child_count()).override_failure_message(
|
||||
"Only 3 options must render even when server sends 4 (D-061 truncation)"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-063: Confrontation beat
|
||||
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
|
||||
## Panel dims during beat. confrontation_monologue signal fires.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d063_beat_duration_within_spec() -> void:
|
||||
## D-063: The beat duration must be 1–2 seconds per spec.
|
||||
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
|
||||
"D-063: confrontation beat must be 1.0–2.0 seconds"
|
||||
).is_between(1.0, 2.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_dim_alpha_is_set() -> void:
|
||||
## D-063: The dialogue box dims during the confrontation beat.
|
||||
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
|
||||
).is_less(1.0)
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
|
||||
).is_greater(0.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
var received_text := ""
|
||||
box.confrontation_monologue.connect(func(text: String, _dur: float):
|
||||
signal_fired = true
|
||||
received_text = text
|
||||
)
|
||||
|
||||
# Show dialogue with one confrontation option
|
||||
var opts := _make_options(["I know what you did."], [true])
|
||||
box.show_dialogue("NPC", "Everything is fine.", opts)
|
||||
|
||||
# Press option 1 (index 0)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
|
||||
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
box.confrontation_monologue.connect(func(_text: String, _dur: float):
|
||||
signal_fired = true
|
||||
)
|
||||
|
||||
var opts := _make_options(["A normal response."], [false])
|
||||
box.show_dialogue("NPC", "Hello.", opts)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue must NOT fire for standard options"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-064: Walk-away mechanic
|
||||
## WASD during active conversation fires dialogue_dismissed signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d064_walk_away_actions_constant_not_empty() -> void:
|
||||
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
|
||||
## Prevents accidental empty-array regression.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
|
||||
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
|
||||
).is_greater_equal(4)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
|
||||
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var actions: Array = box._WALK_AWAY_ACTIONS
|
||||
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
|
||||
assert_bool(required in actions).override_failure_message(
|
||||
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_dialogue_dismissed_signal_connection() -> void:
|
||||
## D-064: dialogue_dismissed signal must exist on DialogueBox.
|
||||
## The signal drives walk-away behavior in main.gd.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
|
||||
"D-064: dialogue_dismissed signal must exist on DialogueBox"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_dialogue snapshot parsing
|
||||
## D-061: current_dialogue is set from snapshot, null when absent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
|
||||
## apply_snapshot with current_dialogue dict populates the field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael Davan",
|
||||
"npc_entity_id": 42,
|
||||
"speech": "You don't belong here.",
|
||||
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_absent() -> void:
|
||||
## apply_snapshot without current_dialogue clears the field.
|
||||
## Prevents stale dialogue from persisting across ticks.
|
||||
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
|
||||
## Non-dict current_dialogue is rejected — defensive against malformed server data.
|
||||
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
|
||||
## The options array must survive snapshot parsing for DialogueBox to render them.
|
||||
var options := [
|
||||
{"text": "A", "response_id": "r1", "priority": 1},
|
||||
{"text": "B", "response_id": "r2", "priority": 2},
|
||||
]
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "NPC",
|
||||
"npc_entity_id": 1,
|
||||
"speech": "Choose.",
|
||||
"options": options,
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
|
||||
assert_int(parsed_opts.size()).is_equal(2)
|
||||
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result snapshot parsing (test-first, #174)
|
||||
## Sprint 18: examine verb returns character-filtered observation text.
|
||||
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
|
||||
## GameState must expose current_examine_result for the overlay display node.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
## GameState must have a current_examine_result field (Sprint 18, #174).
|
||||
## Fails until Stig adds the field to game_state.gd.
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
## current_examine_result defaults to null (no examine active).
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
## apply_snapshot with examine_result dict populates current_examine_result.
|
||||
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5,
|
||||
"examine_result": {
|
||||
"entity_id": 12,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
},
|
||||
})
|
||||
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:
|
||||
## apply_snapshot without examine_result must clear the field.
|
||||
## Prevents stale examine overlay persisting beyond auto-dismiss window.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
## Malformed examine_result (not a dict) must be rejected.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
|
||||
return
|
||||
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:
|
||||
## entity_id is needed to anchor the overlay above the correct entity.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
|
||||
})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode injection guard (regression: Hoshe #2)
|
||||
## Server-sourced text containing BBCode brackets must be escaped.
|
||||
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||
## as plain text in the dialogue log.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).contains("[lb]")
|
||||
assert_bool(escaped.begins_with("[")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_plain_text_unchanged() -> void:
|
||||
## Non-BBCode text must not be modified by _escape_bbcode.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var plain := "Kael Davan"
|
||||
assert_that(box._escape_bbcode(plain)).is_equal(plain)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_multiple_brackets() -> void:
|
||||
## Multiple '[' characters all get escaped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var text := "[b]Bold[/b] and [i]italic[/i]"
|
||||
var escaped: String = box._escape_bbcode(text)
|
||||
assert_bool(escaped.contains("[b]")).is_false()
|
||||
assert_bool(escaped.contains("[i]")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log dirty flag optimization (regression: Hoshe #1)
|
||||
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_log_dirty_false_on_init() -> void:
|
||||
## _log_dirty starts false — no rebuild needed before first line.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false on init — no unnecessary rebuild"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_set_after_append_line() -> void:
|
||||
## Appending a line sets _log_dirty = true.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hello.", false)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be true after append_line to trigger rebuild next _process"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_cleared_after_process() -> void:
|
||||
## After _process(), _log_dirty is cleared (rebuild done).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "One line.", false)
|
||||
assert_bool(box._log_dirty).is_true()
|
||||
box._process(0.0)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false after _process (rebuild consumed the flag)"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-061: Dialogue box size constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d061_max_height_ratio_is_twenty_percent() -> void:
|
||||
## D-061: dialogue box must occupy max 20% viewport height.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
|
||||
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d061_fade_in_is_200ms() -> void:
|
||||
## D-061: fade-in on dialogue appearance is 0.2s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_IN).override_failure_message(
|
||||
"D-061: FADE_IN must be 0.2s"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_fade_out_is_300ms() -> void:
|
||||
## D-064: walk-away fade-out is 300ms per spec.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_OUT).override_failure_message(
|
||||
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
|
||||
).is_equal_approx(0.3, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passive log entry (D-078: overheard NPC-NPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_passive_entry_uses_bar_glyph() -> void:
|
||||
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
|
||||
## Verifies the glyph constant is the correct Unicode bar character.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_that(box.PASSIVE_GLYPH).override_failure_message(
|
||||
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
|
||||
).is_equal("\u2503 ")
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_appended_and_marked_is_passive() -> void:
|
||||
## D-078: append_conversation_event creates a passive log entry.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var event := {
|
||||
"speaker_id": 10, "target_id": 11,
|
||||
"speaker_name": "Guard A", "target_name": "Guard B",
|
||||
"occluded_line": "Did you see the detective?",
|
||||
"speaker_color_index": 0, "target_color_index": 1,
|
||||
}
|
||||
box.append_conversation_event(event)
|
||||
|
||||
assert_int(box._log_entries.size()).is_equal(1)
|
||||
assert_bool(box._log_entries[0].is_passive).override_failure_message(
|
||||
"D-078: overheard entry must be marked is_passive = true"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_empty_text_not_appended() -> void:
|
||||
## D-078: Conversation event with empty occluded_line is silently dropped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.append_conversation_event({
|
||||
"speaker_id": 1, "target_id": 2,
|
||||
"speaker_name": "A", "target_name": "B",
|
||||
"occluded_line": "",
|
||||
})
|
||||
assert_int(box._log_entries.size()).override_failure_message(
|
||||
"D-078: empty occluded_line must not produce a log entry"
|
||||
).is_equal(0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_active_entries / is_dialogue_active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_has_active_entries_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.has_active_entries()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_has_active_entries_true_after_append() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hey.", false)
|
||||
assert_bool(box.has_active_entries()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.is_dialogue_active()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(box.is_dialogue_active()).is_true()
|
||||
box.queue_free()
|
||||
@@ -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,601 @@
|
||||
## Sprint 18 — Knowledge/journal display (#264)
|
||||
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||
## D-042 (UIStrings for all labels)
|
||||
##
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||
|
||||
func _make_journal_panel() -> Control:
|
||||
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_tick": 1024,
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||
GameState.player_knowledge = null
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"player_knowledge": _make_player_knowledge(),
|
||||
})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_journal_panel_instantiates_without_crash() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = null
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_confidence_direct_exists() -> void:
|
||||
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||
## All four confidence label values must be non-empty strings.
|
||||
var keys := [
|
||||
"knowledge_panel.confidence_direct",
|
||||
"knowledge_panel.confidence_knowsdetails",
|
||||
"knowledge_panel.confidence_knowsof",
|
||||
"knowledge_panel.confidence_suspects",
|
||||
]
|
||||
for key in keys:
|
||||
if not UIStrings.has_key(key): continue
|
||||
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||
"UIStrings key '%s' must be non-empty" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_directobservation_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_toldby_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_heard_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||
## UIStrings falls back to the key string itself for missing keys.
|
||||
## journal_panel.gd relies on this for graceful degradation.
|
||||
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||
assert_that(fallback).override_failure_message(
|
||||
"UIStrings fallback must return the key string itself for unknown keys"
|
||||
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_insert_constant_is_10() -> void:
|
||||
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||
|
||||
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
@@ -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")
|
||||
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
|
||||
|
||||
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
|
||||
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
||||
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null()
|
||||
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
||||
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
|
||||
@@ -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]
|
||||
+53
-24
@@ -354,26 +354,45 @@ func _show_options(options: Array) -> void:
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
|
||||
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
||||
label.text = numbered_text
|
||||
# D-063: Confrontation options render italic — first-person voice, weighted differently.
|
||||
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
|
||||
var ctrl: Control
|
||||
if is_confrontation:
|
||||
var rtl := RichTextLabel.new()
|
||||
rtl.bbcode_enabled = true
|
||||
rtl.fit_content = true
|
||||
rtl.scroll_active = false
|
||||
rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
rtl.add_theme_color_override("default_color", Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)) # Slight warm tint for confrontation weight
|
||||
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
|
||||
ctrl = rtl
|
||||
else:
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
label.text = "%d. %s" % [i + 1, raw_text]
|
||||
ctrl = label
|
||||
|
||||
var idx := i
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
ctrl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
label.mouse_entered.connect(_make_hover_on(label))
|
||||
label.mouse_exited.connect(_make_hover_off(label))
|
||||
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||
|
||||
options_container.add_child(label)
|
||||
_option_controls.append(label)
|
||||
options_container.add_child(ctrl)
|
||||
_option_controls.append(ctrl)
|
||||
_option_response_ids.append(opt.get("response_id", ""))
|
||||
_option_texts.append(raw_text)
|
||||
_option_is_confrontation.append(is_confrontation)
|
||||
@@ -519,7 +538,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]")
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
|
||||
|
||||
## Get a stable color for a character name, with contrast floor enforcement.
|
||||
@@ -575,19 +594,22 @@ func _expire_entries() -> void:
|
||||
var removed := false
|
||||
var has_fading := false
|
||||
|
||||
# Remove expired non-pinned entries from the front (oldest first)
|
||||
while _log_entries.size() > 0:
|
||||
var entry: Dictionary = _log_entries[0]
|
||||
# Remove expired non-pinned entries (oldest first, skipping pinned)
|
||||
var i := 0
|
||||
while i < _log_entries.size():
|
||||
var entry: Dictionary = _log_entries[i]
|
||||
if entry.pinned:
|
||||
break # Pinned entries never expire
|
||||
i += 1
|
||||
continue # Pinned entries never expire — keep scanning
|
||||
var age: int = now - entry.timestamp_msec
|
||||
if age < total_lifetime_msec:
|
||||
# Check if this entry is in the fading phase
|
||||
if age > int(_entry_lifetime * 1000.0):
|
||||
has_fading = true
|
||||
break
|
||||
_log_entries.remove_at(0)
|
||||
_log_entries.remove_at(i)
|
||||
removed = true
|
||||
# Don't increment i — element at i is now the next entry
|
||||
|
||||
# Check remaining entries for fading state
|
||||
if not has_fading:
|
||||
@@ -623,11 +645,18 @@ func _clear_options() -> void:
|
||||
|
||||
|
||||
# Hover callbacks
|
||||
static func _make_hover_on(label: Control) -> Callable:
|
||||
static func _make_hover_on(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
# RichTextLabel uses "default_color"; Label uses "font_color"
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
|
||||
static func _make_hover_off(label: Control) -> Callable:
|
||||
static func _make_hover_off(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Control
|
||||
|
||||
## Examine result display — #174, D-061 adjacent.
|
||||
##
|
||||
## Shows the character-filtered text returned by the Examine verb (#242).
|
||||
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
|
||||
## Diegetic: reads as the neural insert processing what the character observed.
|
||||
##
|
||||
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
|
||||
## Only one examine result is shown at a time — new result replaces old.
|
||||
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.35
|
||||
|
||||
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
|
||||
const CONFIDENCE_ALPHA: Dictionary = {
|
||||
"Direct": 1.0,
|
||||
"KnowsDetails": 0.9,
|
||||
"KnowsOf": 0.75,
|
||||
"Suspects": 0.6,
|
||||
}
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
var _dismiss_tween: Tween = null
|
||||
var _active: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
|
||||
|
||||
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
|
||||
## result: {entity_id, text, confidence}
|
||||
func show_result(result: Dictionary) -> void:
|
||||
var text: String = result.get("text", "")
|
||||
var confidence: String = result.get("confidence", "KnowsOf")
|
||||
|
||||
if text.is_empty():
|
||||
return
|
||||
|
||||
# Cancel any in-progress dismiss
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
|
||||
# Apply confidence-based alpha to the insert color
|
||||
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
|
||||
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b, alpha)
|
||||
text_label.add_theme_color_override("default_color", col)
|
||||
text_label.text = text
|
||||
|
||||
visible = true
|
||||
_active = true
|
||||
modulate.a = 0.0
|
||||
|
||||
# Fade in, then auto-dismiss
|
||||
_dismiss_tween = create_tween()
|
||||
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
_dismiss_tween.tween_interval(DISMISS_DELAY)
|
||||
_dismiss_tween.tween_callback(_start_fade_out)
|
||||
|
||||
|
||||
func _start_fade_out() -> void:
|
||||
if not _active:
|
||||
return
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func():
|
||||
visible = false
|
||||
_active = false
|
||||
)
|
||||
|
||||
|
||||
## Dismiss immediately (e.g. when dialogue opens).
|
||||
func dismiss() -> void:
|
||||
if not _active:
|
||||
return
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
_start_fade_out()
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
@@ -0,0 +1,50 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
|
||||
|
||||
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
|
||||
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
|
||||
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
|
||||
|
||||
[node name="ExamineDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = -440.0
|
||||
offset_top = 120.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 240.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_examine")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 8
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
mouse_filter = 2
|
||||
theme_override_font_sizes/normal_font_size = 13
|
||||
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)
|
||||
@@ -0,0 +1,214 @@
|
||||
extends Control
|
||||
|
||||
## Journal panel — knowledge graph review. #264, D-041.
|
||||
##
|
||||
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
|
||||
## grouped by entity. Read-only — no player interaction beyond scrolling.
|
||||
##
|
||||
## KnowledgeState rendering:
|
||||
## Active → normal color (INSERT_COLOR_TEXT)
|
||||
## Stale → dimmed (IMPLANT_TEXT_DIM)
|
||||
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
|
||||
##
|
||||
## KnowledgeConfidence labels (D-041):
|
||||
## Direct → KnowsDetails → KnowsOf → Suspects
|
||||
##
|
||||
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
|
||||
## Closes when dialogue opens.
|
||||
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.25
|
||||
|
||||
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
var _visible_state: bool = false
|
||||
var _last_rendered_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
modulate.a = 0.0
|
||||
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
|
||||
|
||||
|
||||
## Toggle open/close. Called from main.gd on J key press.
|
||||
func toggle() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
else:
|
||||
_show_panel()
|
||||
|
||||
|
||||
## Force-close. Called when dialogue opens.
|
||||
func close() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _visible_state
|
||||
|
||||
|
||||
func _show_panel() -> void:
|
||||
_rebuild_entries()
|
||||
_visible_state = true
|
||||
visible = true
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide_panel() -> void:
|
||||
_visible_state = false
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
## Rebuild the entry list from GameState.player_knowledge.
|
||||
func _rebuild_entries() -> void:
|
||||
# Clear previous entries
|
||||
for child in entries_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
var entities: Array = knowledge.get("entities", [])
|
||||
if entities.is_empty():
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
for entity in entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
_add_entity_entry(entity)
|
||||
|
||||
|
||||
func _add_empty_state() -> void:
|
||||
var label := Label.new()
|
||||
label.text = UIStrings.get_text("knowledge_panel.empty_state")
|
||||
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
entries_container.add_child(label)
|
||||
|
||||
|
||||
func _add_entity_entry(entity: Dictionary) -> void:
|
||||
var name_str: String = entity.get("name", "Unknown")
|
||||
var confidence: String = entity.get("confidence", "Suspects")
|
||||
var source: String = entity.get("source", "")
|
||||
var state: String = entity.get("state", "Active")
|
||||
var relationship: String = entity.get("relationship", "Unknown")
|
||||
|
||||
# Entity header — name + relationship status
|
||||
var header_rtl := RichTextLabel.new()
|
||||
header_rtl.bbcode_enabled = true
|
||||
header_rtl.fit_content = true
|
||||
header_rtl.scroll_active = false
|
||||
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header_rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
|
||||
var rel_color: Color = Constants.color_for_relationship(relationship)
|
||||
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
|
||||
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
|
||||
var name_hex: String = state_color.to_html(false)
|
||||
var rel_hex: String = rel_color.to_html(false)
|
||||
|
||||
var header_bbcode: String
|
||||
if state == "Contradicted":
|
||||
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
else:
|
||||
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_rtl.text = header_bbcode
|
||||
entries_container.add_child(header_rtl)
|
||||
|
||||
# Detail line — confidence + source
|
||||
var detail_rtl := RichTextLabel.new()
|
||||
detail_rtl.bbcode_enabled = true
|
||||
detail_rtl.fit_content = true
|
||||
detail_rtl.scroll_active = false
|
||||
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
|
||||
|
||||
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
|
||||
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
|
||||
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
|
||||
entries_container.add_child(detail_rtl)
|
||||
|
||||
# Spacer between entries
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 6)
|
||||
entries_container.add_child(spacer)
|
||||
|
||||
|
||||
## Resolve a source string from the server into a human-readable label.
|
||||
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
|
||||
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
|
||||
func _resolve_source_label(source: String) -> String:
|
||||
if source.is_empty():
|
||||
return ""
|
||||
|
||||
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
|
||||
if source.begins_with("ToldBy(") and source.ends_with(")"):
|
||||
var id_str: String = source.substr(7, source.length() - 8)
|
||||
var entity_id: int = id_str.to_int()
|
||||
var name := _entity_name_for_id(entity_id)
|
||||
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
|
||||
if told_prefix == "knowledge_panel.source_toldby":
|
||||
told_prefix = "Told"
|
||||
return "%s: %s" % [told_prefix, name]
|
||||
|
||||
# Plain source key — look up UIStrings
|
||||
var key: String = "knowledge_panel.source_%s" % source.to_lower()
|
||||
return UIStrings.get_text(key)
|
||||
|
||||
|
||||
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
|
||||
func _entity_name_for_id(entity_id: int) -> String:
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
return "#%d" % entity_id
|
||||
for entity in knowledge.get("entities", []):
|
||||
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
|
||||
return entity.get("name", "#%d" % entity_id)
|
||||
return "#%d" % entity_id
|
||||
|
||||
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
"Stale":
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # active — normal
|
||||
|
||||
|
||||
## Called from main.gd on each snapshot to close the panel when dialogue opens.
|
||||
func update_from_state() -> void:
|
||||
if _visible_state and GameState.dialogue_active:
|
||||
close()
|
||||
# Rebuild if open and knowledge data is newer than last render
|
||||
if _visible_state and GameState.current_tick != _last_rendered_tick:
|
||||
_last_rendered_tick = GameState.current_tick
|
||||
_rebuild_entries()
|
||||
@@ -0,0 +1,63 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"]
|
||||
|
||||
; JournalPanel — knowledge graph review. #264, D-041.
|
||||
; Toggle J key. Right side, 380px wide, full height minus margins.
|
||||
; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue.
|
||||
|
||||
[node name="JournalPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -400.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_journal")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 14
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 14
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Contacts"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9)
|
||||
|
||||
[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 2
|
||||
@@ -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()
|
||||
+166
-9
@@ -1,15 +1,172 @@
|
||||
class_name MinimapRenderer
|
||||
extends Control
|
||||
|
||||
# Minimap — small viewport showing local area
|
||||
# Placeholder implementation for now
|
||||
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
|
||||
##
|
||||
## Player always centered. Fixed-north (no rotation per D-015).
|
||||
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
|
||||
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
|
||||
## Frame renders always — the insert is on even when no POIs are discovered.
|
||||
##
|
||||
## POI categories → shapes:
|
||||
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
|
||||
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
|
||||
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
|
||||
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
|
||||
## (default) → circle (INSERT_COLOR_TEXT)
|
||||
|
||||
@onready var viewport_container: SubViewportContainer = $SubViewportContainer
|
||||
## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
|
||||
const MINIMAP_RADIUS: float = 24.0
|
||||
|
||||
# Visual parameters
|
||||
const FRAME_WIDTH: float = 1.2
|
||||
const PLAYER_DOT_RADIUS: float = 3.5
|
||||
const POI_DOT_RADIUS: float = 3.0
|
||||
const ARROW_HALF: float = 4.5
|
||||
const ARROW_LEN: float = 7.0
|
||||
const NORTH_TICK_LEN: float = 8.0
|
||||
const CARDINAL_TICK_LEN: float = 4.0
|
||||
|
||||
# Colors — insert palette from Constants, tuned for the circular minimap frame
|
||||
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
|
||||
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
print("Minimap: Initialized")
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(true)
|
||||
|
||||
# Update minimap view (stub)
|
||||
func update_minimap(player_pos: Vector2, entities: Array) -> void:
|
||||
# TODO: Render minimap view
|
||||
# This will show a top-down view of the local area
|
||||
pass
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active:
|
||||
queue_redraw()
|
||||
|
||||
## Called from main.gd when GameState.insert_active changes.
|
||||
## Hides the minimap overlay when the neural insert is inactive.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
visible = active
|
||||
|
||||
func _draw() -> void:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center := sz / 2.0
|
||||
# Outer radius: fill control with 1px edge padding
|
||||
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
|
||||
|
||||
# --- Background fill ---
|
||||
draw_circle(center, outer_r, COLOR_BG)
|
||||
|
||||
# --- Frame ring ---
|
||||
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
|
||||
|
||||
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
|
||||
_draw_cardinal_ticks(center, outer_r)
|
||||
|
||||
# --- Player dot at center ---
|
||||
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
|
||||
# Soft bloom ring
|
||||
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
|
||||
|
||||
# --- POIs ---
|
||||
var pois: Array = GameState.discovered_pois
|
||||
if pois.is_empty():
|
||||
return
|
||||
|
||||
var px: float = GameState.player_position.x
|
||||
var py: float = GameState.player_position.y
|
||||
# Pixels per sim tile within the inner drawable area
|
||||
var inner_r: float = outer_r - FRAME_WIDTH
|
||||
var scale: float = inner_r / MINIMAP_RADIUS
|
||||
|
||||
for poi in pois:
|
||||
if not poi is Dictionary:
|
||||
continue
|
||||
if not poi.has("x") or not poi.has("y"):
|
||||
continue
|
||||
|
||||
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("poi_category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
# POI at exact player position — draw at center offset slightly
|
||||
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
|
||||
elif dist <= MINIMAP_RADIUS:
|
||||
# Nearby: project to screen position within circle
|
||||
var poi_screen := center + Vector2(dx, dy) * scale
|
||||
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
|
||||
var rel := poi_screen - center
|
||||
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
|
||||
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
|
||||
_draw_poi_shape(poi_screen, color, category)
|
||||
else:
|
||||
# Distant: arrow at border pointing toward POI direction
|
||||
var dir := Vector2(dx, dy).normalized()
|
||||
var arrow_tip := center + dir * (inner_r - 2.0)
|
||||
_draw_border_arrow(arrow_tip, dir, color)
|
||||
|
||||
|
||||
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
# North tick — longer, brighter, the fixed-north indicator
|
||||
var n_dir := Vector2(0.0, -1.0)
|
||||
draw_line(
|
||||
center + n_dir * (outer_r - NORTH_TICK_LEN),
|
||||
center + n_dir * outer_r,
|
||||
COLOR_NORTH, FRAME_WIDTH + 0.5, true
|
||||
)
|
||||
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
|
||||
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
|
||||
var dir := Vector2(cos(angle), sin(angle))
|
||||
draw_line(
|
||||
center + dir * (outer_r - CARDINAL_TICK_LEN),
|
||||
center + dir * outer_r,
|
||||
COLOR_CARDINAL, FRAME_WIDTH, true
|
||||
)
|
||||
|
||||
|
||||
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
# Diamond for danger
|
||||
var s: float = POI_DOT_RADIUS + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
|
||||
]),
|
||||
PackedColorArray([color, color, color, color])
|
||||
)
|
||||
"evidence", "note", "clue":
|
||||
# Square for evidence/clue
|
||||
var s: float = POI_DOT_RADIUS - 0.5
|
||||
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
|
||||
_:
|
||||
draw_circle(pos, POI_DOT_RADIUS, color)
|
||||
|
||||
|
||||
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
|
||||
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := tip - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
func _category_color(category: String) -> Color:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
"evidence", "note", "clue":
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
"contact", "npc", "person":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
|
||||
+11
-23
@@ -2,33 +2,21 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"]
|
||||
|
||||
[node name="Minimap" type="Control"]
|
||||
; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6).
|
||||
; 160x160px circle, top-right corner with 16px margin.
|
||||
; Rendered via _draw() — no SubViewport needed.
|
||||
; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn.
|
||||
|
||||
[node name="MinimapRenderer" type="Control"]
|
||||
custom_minimum_size = Vector2(160, 160)
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -200.0
|
||||
offset_bottom = 200.0
|
||||
offset_left = -176.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 176.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_minimap")
|
||||
|
||||
[node name="SubViewportContainer" type="SubViewportContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"]
|
||||
size = Vector2i(200, 200)
|
||||
render_target_update_mode = 4
|
||||
|
||||
[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.1, 0.1, 0.1, 0.7)
|
||||
|
||||
@@ -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 |
|
||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
|
||||
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, 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-028 |
|
||||
| [questions.md](questions.md) | Open questions | Q-001 through Q-039 |
|
||||
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
||||
|
||||
## Querying Decisions
|
||||
|
||||
@@ -210,4 +210,28 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*16 decisions. Last updated: 2026-02-16*
|
||||
### D-085: Per-game save directory structure
|
||||
- **Date:** 2026-02-25
|
||||
- **Decision:** Every new game creates a dedicated directory under the user save path. All saves for that game (manual, quicksave, autosave) live inside the game's directory. Directory name includes a human-readable game identifier and creation timestamp.
|
||||
- **Rationale:** Natively groups saves by game without requiring a database or index file. Players can browse, back up, or delete game saves at the filesystem level. Avoids a flat save folder where 50+ files from different games are interleaved.
|
||||
- **Structure:** `user://saves/<game-id>/` where `<game-id>` is `<timestamp>-<seed>` (e.g., `20260225-143022-a7b3f1/`). Inside: `quicksave.sav`, `autosave.sav`, `manual_001.sav`, etc.
|
||||
- **Constraints:**
|
||||
- Game directory created on "New Game" — even before the first save, so the path exists for quicksave/autosave.
|
||||
- F5 = quicksave (overwrites `quicksave.sav` in the active game dir).
|
||||
- F6 = quickload (loads `quicksave.sav` from the active game dir).
|
||||
- Loading screen lists game directories sorted by last-modified, shows most recent save per game.
|
||||
- **Raised by:** Team Leader (Jeroen)
|
||||
- **Dissent:** None
|
||||
|
||||
### 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)
|
||||
|
||||
---
|
||||
|
||||
*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)*
|
||||
|
||||
+20
-1
@@ -446,6 +446,25 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain)
|
||||
- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation)
|
||||
|
||||
### D-086: Insert icon system — custom SVG, no icon font
|
||||
- **Date:** 2026-02-24
|
||||
- **Decision:** The insert overlay (z-layer 6) uses **custom SVG icons**, not an icon font. The v0.1 icon vocabulary (~12–15 shapes: stance indicators, perception mode icons, inventory item silhouettes, border arrows) is too small and too specific for a font. No available icon font (Material Symbols, Phosphor, Feather, Tabler) matches the insert's geometric/diegetic visual language — they all read as "app UI," not "neural lattice overlay." Custom SVGs authored to the insert's constraint set (clean geometry, minimal anchor points, `#c8d0e0` chrome color, 1px stroke base weight) ensure all insert elements feel generated by the same system.
|
||||
- **Why not an icon font:**
|
||||
- **Vocabulary mismatch:** No font has "hexagonal corridor access token" or "thermal perception mode" in a sci-fi register. Inventory silhouettes are universe-specific — we author them regardless.
|
||||
- **Weight precision:** The spec defines 1px (smuggler/baseline) vs 1–2px (detective/augmented) line weight. Custom SVG `stroke-width` responds to `lattice_profile` parameter directly. Icon fonts can't vary weight per-character in a single rendering pass.
|
||||
- **Visual coherence:** Mixing a generic icon font with the custom geometric primitives (diamond POI markers, 6px dots, 4px evidence squares, commission grid) creates two visual registers on the same UI layer. All insert elements must feel like they came from one design system.
|
||||
- **Godot integration:** Godot 4 handles SVG natively (AtlasTexture, importable SVGs). Runtime color via ShaderMaterial. No font rendering pipeline or bitmap caching needed.
|
||||
- **Icon vocabulary (v0.1):**
|
||||
- Inventory item silhouettes: 3–4 items at 40×40px (manifest, access token, comm log) — smuggler only per [D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence)
|
||||
- Perception mode indicators: 2–3 icons at 20×20px (deferred until #315 specs the modes)
|
||||
- Stance indicators: text labels (`WALK`, `CAREFUL`, `SPRINT`, `CROUCH`) at Michroma 11px — icons deferred to v0.2
|
||||
- Border arrows and geometric markers (diamonds, dots, squares): drawn as primitives, not icons
|
||||
- **Authoring constraints:** 1px stroke weight base (scaled to 2px for `lattice_augmented` via parameter), `#c8d0e0` as design reference color (runtime-replaceable), minimal anchor points, no decorative curves.
|
||||
- **Calibration reference:** Phosphor Light (MIT) — not for use, but for evaluating small-size readability of custom shapes during authoring. If a custom icon doesn't read as cleanly as a Phosphor Light equivalent at 16px, simplify it.
|
||||
- **Cross-reference:** Insert overlay ([D-048](#d-048-neural-insert-overlay--visual-design)), insert HUD wireframe (`docs/design/insert-hud-wireframe-v01.md`), visual grammar (`docs/design/visual-grammar-v01.md` §7), cursor states ([D-056](#d-056-cursor-states--insert-styled-geometric)), inventory ([D-065](scope.md#d-065-smuggler-inventory--knowledge-primary-with-physical-evidence))
|
||||
- **Raised by:** Araminta (analysis and recommendation), project lead (confirmed).
|
||||
- **Dissent:** None.
|
||||
|
||||
---
|
||||
|
||||
*37 decisions. Last updated: 2026-02-24 (D-079–D-083: Knowledge Flow & NPC Information Boundaries Workshop)*
|
||||
*38 decisions. Last updated: 2026-02-24 (D-086: Insert icon system)*
|
||||
|
||||
+75
-1
@@ -168,6 +168,80 @@ Tracked questions awaiting discussion or resolution.
|
||||
- **Assigned to:** Gestalt, Tyre
|
||||
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
||||
|
||||
### Q-029: Save file format design
|
||||
- **Status:** Open
|
||||
- **Question:** What should the long-term save file format look like? Key considerations:
|
||||
1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load?
|
||||
2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward?
|
||||
3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header?
|
||||
4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save?
|
||||
5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable?
|
||||
6. **Modding:** Should the format be documented for mod authors? Does it need extension points?
|
||||
7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits?
|
||||
- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Team Leader directive (Sprint 19 planning)
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
*28 questions (7 resolved, 1 partially resolved, 20 open). Last updated: 2026-02-25 (Q-028 resolved → D-084)*
|
||||
*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
|
||||
|
||||
Binary file not shown.
@@ -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,190 @@
|
||||
# Test Plan: Sprint 18 — Touch (Client)
|
||||
|
||||
- **Date**: 2026-02-25
|
||||
- **Sprint**: 18 (Touch)
|
||||
- **Spec references**: D-013, D-041, D-042, D-049, D-061, D-062, D-063, D-064, D-078
|
||||
- **Tickets**: #151 (minimap rendering), #174 (dialogue UI hardening + examine result), #264 (knowledge/journal display)
|
||||
- **QA Engineer**: Hoshe
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Sprint 18 client scope delivers three UI features:
|
||||
1. **#151** — Minimap overlay rendering POIs from snapshot
|
||||
2. **#174** — Dialogue UI hardening (D-062/D-063/D-064) + examine result display overlay
|
||||
3. **#264** — Knowledge/journal panel displaying accumulated KG facts
|
||||
|
||||
Automated tests: `client/tests/test_dialogue_sprint18.gd`, `client/tests/test_journal_sprint18.gd`
|
||||
Manual tests: this document (section §Manual Test Procedures)
|
||||
|
||||
---
|
||||
|
||||
## #151: Minimap Rendering
|
||||
|
||||
### Spec reference
|
||||
D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), D-049 (z-layer 6)
|
||||
|
||||
### Automated (unit-testable)
|
||||
- `GameState.poi_list` accessible via snapshot or `current_snapshot.poi_list`
|
||||
→ Covered: `test_journal_sprint18.gd::test_gamestate_poi_list_field_exists_or_in_entities`
|
||||
- `CANVAS_INSERT = 10` sanity check
|
||||
→ Covered: `test_journal_sprint18.gd::test_canvas_insert_constant_is_10`
|
||||
|
||||
### Edge cases
|
||||
- **Empty POI list**: minimap frame still renders (frame is always present per D-013)
|
||||
- **POI beyond minimap radius**: renders as directional arrow at border, not dot
|
||||
- **POI at exactly player position**: dot at center
|
||||
- **All POI categories**: `NavPoint`, `PersonOfInterest` — distinct colors/shapes
|
||||
|
||||
### Manual test procedure
|
||||
1. Start game with a clean save (no discovered POIs)
|
||||
2. **Verify**: minimap insert frame is visible, empty, no dots/arrows
|
||||
3. Move player near a NavPoint POI; trigger discovery
|
||||
4. **Verify**: colored dot appears on minimap at correct compass position
|
||||
5. **Verify**: dot color/shape matches expected category visual (see `data/ui-strings.yaml`)
|
||||
6. Move player so a POI is beyond minimap radius
|
||||
7. **Verify**: directional arrow appears at minimap border pointing toward POI
|
||||
8. **Verify**: player dot remains centered; minimap does not rotate or scroll
|
||||
9. Open dialogue box; **verify**: minimap remains visible (not hidden by dialogue)
|
||||
|
||||
### Performance check
|
||||
- 150×150 map, 15 NPCs, 8+ discovered POIs → minimap renders without visible frame drop
|
||||
|
||||
---
|
||||
|
||||
## #174: Dialogue UI Hardening + Examine Result Display
|
||||
|
||||
### Spec reference
|
||||
D-061 (box spec), D-062 (invisible locked options), D-063 (confrontation), D-064 (walk-away), D-078 (overheard log)
|
||||
|
||||
### Automated coverage
|
||||
Test file: `client/tests/test_dialogue_sprint18.gd`
|
||||
|
||||
| Test | D-ref | Status |
|
||||
|------|-------|--------|
|
||||
| D-062: rendered options have mouse_filter=STOP | D-062 | Written |
|
||||
| D-062: no lock icon children on options | D-062 | Written |
|
||||
| D-062: MAX_OPTIONS = 3 | D-061 | Written |
|
||||
| D-062: 4 options → only 3 render | D-061/D-062 | Written |
|
||||
| D-063: CONFRONTATION_BEAT_DURATION in [1.0, 2.0] | D-063 | Written |
|
||||
| D-063: CONFRONTATION_DIM_ALPHA < 1.0 | D-063 | Written |
|
||||
| D-063: confrontation_monologue signal fires | D-063 | Written |
|
||||
| D-063: standard option does NOT fire beat signal | D-063 | Written |
|
||||
| D-064: _WALK_AWAY_ACTIONS not empty | D-064 | Written |
|
||||
| D-064: cardinal directions in walk-away list | D-064 | Written |
|
||||
| D-064: dialogue_dismissed signal exists | D-064 | Written |
|
||||
| GameState current_dialogue set from snapshot | D-061 | Written |
|
||||
| GameState current_dialogue null when absent | D-061 | Written |
|
||||
| GameState current_examine_result field exists | #174 | Written (test-first) |
|
||||
| GameState current_examine_result set from snapshot | #174 | Written (test-first) |
|
||||
| GameState current_examine_result null when absent | #174 | Written (test-first) |
|
||||
| BBCode escape brackets in server text | — | Written |
|
||||
| _log_dirty flag optimization | — | Written |
|
||||
| D-061 max height ratio = 0.2 | D-061 | Written |
|
||||
| D-064 FADE_OUT = 0.3s | D-064 | Written |
|
||||
| D-078 passive glyph = ┃ | D-078 | Written |
|
||||
|
||||
### Items requiring Stig implementation (test-first stubs will fail until done)
|
||||
- `GameState.current_examine_result` field + `apply_snapshot()` handler
|
||||
- Examine result overlay scene (`res://ui/examine_overlay.tscn` or similar)
|
||||
- Auto-dismiss timer: 4–6 seconds (wire `current_examine_result` to overlay)
|
||||
|
||||
### Manual test procedure — D-062 (invisible locked options)
|
||||
1. Enter dialogue with an NPC that has some filtered options (server omits locked ones)
|
||||
2. **Verify**: dialogue box shows only the options the server sent — no grayed-out entries, no lock icons
|
||||
3. **Verify**: all visible options respond to click/key press
|
||||
4. **Verify**: pressing 1, 2, 3 selects the corresponding option (key bindings active)
|
||||
5. **Red flag**: if you see any visual element that appears "disabled" or "locked" — that is a D-062 violation
|
||||
|
||||
### Manual test procedure — D-063 (confrontation beat)
|
||||
1. Enter dialogue with an NPC that has a confrontation option (italic monologue beat)
|
||||
2. **Verify**: confrontation option renders identically to standard options (same style — no bold, no icon)
|
||||
3. Select the confrontation option
|
||||
4. **Verify**: a first-person internal monologue appears (italic, MonologueDisplay)
|
||||
5. **Verify**: dialogue box dims for ~1.5 seconds during beat
|
||||
6. **Verify**: after beat, option is sent and conversation ends normally
|
||||
7. **Verify**: audio dip applies during confrontation beat
|
||||
|
||||
### Manual test procedure — Examine result display (#174 new feature)
|
||||
1. Stand adjacent to an NPC; press Examine key (TBD — coordinate with server team)
|
||||
2. **Verify**: a brief text overlay appears (non-interactive, no response options)
|
||||
3. **Verify**: overlay is diegetically styled (insert layer, not a dialogue box)
|
||||
4. **Verify**: overlay auto-dismisses after 4–6 seconds without player input
|
||||
5. **Verify**: different characters (detective vs smuggler) receive different text for the same NPC
|
||||
6. **Verify**: overlay does not appear over a dialogue box (mutual exclusion)
|
||||
|
||||
---
|
||||
|
||||
## #264: Knowledge/Journal Display
|
||||
|
||||
### Spec reference
|
||||
D-041 (knowledge graph data model), D-042 (UIStrings), D-013 (insert layer)
|
||||
|
||||
### Automated coverage
|
||||
Test file: `client/tests/test_journal_sprint18.gd`
|
||||
|
||||
| Test | D-ref | Status |
|
||||
|------|-------|--------|
|
||||
| GameState.player_knowledge field exists | D-041 | Written (test-first) |
|
||||
| GameState.player_knowledge set from snapshot | D-041 | Written (test-first) |
|
||||
| GameState.player_knowledge null when absent | D-041 | Written (test-first) |
|
||||
| Facts array survives snapshot roundtrip | D-041 | Written (test-first) |
|
||||
| KnowledgeConfidence levels documented | D-041 | Written |
|
||||
| Fact state values documented | D-041 | Written |
|
||||
| Journal scene exists at path | #264 | Written (test-first) |
|
||||
| UIStrings has journal section | D-042 | Written (test-first) |
|
||||
| CANVAS_INSERT = 10 | D-013 | Written |
|
||||
| POI list accessible for minimap | #151 | Written |
|
||||
|
||||
### Items requiring Stig implementation (test-first stubs will fail until done)
|
||||
- `GameState.player_knowledge` field + `apply_snapshot()` handler
|
||||
- Journal panel scene (`res://ui/journal_panel.tscn`)
|
||||
- Journal panel `refresh()` or `_refresh()` method
|
||||
- UIStrings keys: `journal.title`, `journal.confidence.*`, `journal.no_facts`
|
||||
- Toggle key (likely `J`) wired to panel visibility
|
||||
- Mutual exclusion: journal closes when dialogue opens and vice versa
|
||||
|
||||
### Manual test procedure
|
||||
1. Accumulate KG facts by examining NPCs and participating in dialogue
|
||||
2. Press the journal toggle key (likely `J`)
|
||||
3. **Verify**: journal panel opens as an insert-layer overlay (diegetic styling)
|
||||
4. **Verify**: entities are listed with header ("What I know about Kael Davan")
|
||||
5. **Verify**: each fact shows: fact text, confidence level, source, game-time timestamp
|
||||
6. **Verify**: `Direct` confidence facts are most prominent (visually)
|
||||
7. **Verify**: `Stale` facts appear dimmer than `Active` facts
|
||||
8. **Verify**: `Contradicted` facts are visually distinct (strikethrough or amber tint)
|
||||
9. Open dialogue box; **verify**: journal panel closes automatically
|
||||
10. Close dialogue; re-open journal; **verify**: state preserved
|
||||
11. Press `J` again; **verify**: journal panel closes
|
||||
|
||||
### Edge cases
|
||||
- **Empty journal**: no facts accumulated → journal shows "No data" message (UIStrings key)
|
||||
- **Many facts**: 20+ facts → scroll works, panel stays within insert layer bounds
|
||||
- **Contradicted + Stale**: a fact can be both — verify combined visual treatment
|
||||
- **Game time 00:00**: timestamp displays correctly (midnight edge case)
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
| Ticket | Automated tests | Manual procedure documented | Ready to run |
|
||||
|--------|----------------|----------------------------|--------------|
|
||||
| #151 minimap | 2 (structural) | Yes | Blocked on Stig (#151 in_progress) |
|
||||
| #174 dialogue hardening | 25 | Yes | All pass today (code exists) |
|
||||
| #174 examine result | 5 (test-first) | Yes | Blocked (GameState field missing) |
|
||||
| #264 journal display | 8 (test-first) | Yes | Blocked (scene + field missing) |
|
||||
|
||||
### Test files to run
|
||||
```bash
|
||||
# gdUnit4 headless (see docs/DEVOPS.md for full command)
|
||||
# test_dialogue_sprint18.gd — expect: 25 pass (dialogue), 5 fail (examine, test-first)
|
||||
# test_journal_sprint18.gd — expect: 3 pass (constants), 8 fail (test-first)
|
||||
```
|
||||
|
||||
### Sprint 18 completion criteria (client)
|
||||
Per `sprint-18/joint.md`:
|
||||
- [ ] Minimap renders POIs — discovered POI shows dot; player centered; at least one distant POI shows arrow
|
||||
- [ ] Journal panel opens — at least one KG fact with confidence, source, game-time visible
|
||||
- [ ] Examine result displays — overlay fires on Examine, auto-dismisses, differs by character
|
||||
- [ ] No locked/grayed dialogue options anywhere in the UI (D-062)
|
||||
@@ -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,8 +306,13 @@ mod tests {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: None,
|
||||
character_pressure: None,
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
poi_list: vec![],
|
||||
examine_result: None,
|
||||
player_knowledge: None,
|
||||
save_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,8 +441,13 @@ mod tests {
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
follow_state: 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"));
|
||||
|
||||
+185
-3
@@ -5,7 +5,9 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState};
|
||||
pub use crate::knowledge::types::{
|
||||
EntityVisibility, KnowledgeConfidence, KnowledgeState, RelationshipState,
|
||||
};
|
||||
pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
|
||||
/// Wire protocol version for ObserverSnapshot.
|
||||
@@ -15,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 = 13;
|
||||
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.
|
||||
@@ -34,10 +46,14 @@ pub const PROTOCOL_VERSION: u8 = 13;
|
||||
/// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations).
|
||||
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
|
||||
/// follow_state (#241, follow mechanic HUD state).
|
||||
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
|
||||
/// examine_result (#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: 13.
|
||||
/// Protocol version for forward compatibility. Current: 15.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -105,11 +121,36 @@ pub struct ObserverSnapshot {
|
||||
/// Client shows follow indicator with distance, LOS, and tension.
|
||||
#[serde(default)]
|
||||
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
|
||||
/// Character pressure state for client HUD widget (#248).
|
||||
/// Present when pressure is non-zero. Client renders tension indicator.
|
||||
#[serde(default)]
|
||||
pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>,
|
||||
/// RNG seed active at this tick for deterministic replay (#527).
|
||||
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
|
||||
/// None when the RNG resource is unavailable (should not occur in practice).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rng_seed: Option<u64>,
|
||||
/// Discovered POIs for minimap rendering (#151, D-013).
|
||||
/// Contains all POIs the observer has discovered (fact in KG).
|
||||
/// Client renders nearby POIs as dots, distant POIs as directional arrows.
|
||||
/// Empty when no POIs have been discovered.
|
||||
#[serde(default)]
|
||||
pub poi_list: Vec<PoiWire>,
|
||||
/// Character-filtered observation text from Examine verb (#174, #242).
|
||||
/// Present when an examine interaction completed this tick.
|
||||
/// Client displays as non-interactive overlay, auto-dismisses after 4-6 seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub examine_result: Option<ExamineResultWire>,
|
||||
/// Partial knowledge graph dump for journal/knowledge panel (#264, D-041).
|
||||
/// Updated periodically (not every tick — only when KG changes).
|
||||
/// 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)
|
||||
@@ -384,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 {
|
||||
@@ -536,8 +585,141 @@ pub struct DialogueResponseEvent {
|
||||
pub speaker_name: String,
|
||||
}
|
||||
|
||||
/// A discovered POI crossing the wire for minimap rendering (#151).
|
||||
/// Derived from PointOfInterest component + KG fact lookup.
|
||||
/// Client converts position to player-relative vector for minimap display.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PoiWire {
|
||||
/// POI identifier (matches poi_id in PointOfInterest component).
|
||||
pub poi_id: String,
|
||||
/// Display name for minimap label.
|
||||
pub name: String,
|
||||
/// World position in simulation tile coordinates.
|
||||
/// Client converts to player-relative vector for compass placement.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
/// Category for icon/color selection on minimap.
|
||||
pub category: crate::simulation::poi::PoiCategory,
|
||||
}
|
||||
|
||||
/// Character-filtered observation text from Examine verb (#174, #242).
|
||||
/// The server runs the examine through the observer's KG to produce
|
||||
/// text appropriate to what the character knows/sees.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultWire {
|
||||
/// Wire-format entity ID of the examined entity.
|
||||
pub entity_id: u64,
|
||||
/// Character-filtered observation text.
|
||||
pub text: String,
|
||||
/// Observer's confidence level about this entity at time of examine.
|
||||
pub confidence: KnowledgeConfidence,
|
||||
}
|
||||
|
||||
/// Partial knowledge graph dump for the journal panel (#264, D-041).
|
||||
/// Sent when KG state changes. Contains entity knowledge and fact knowledge
|
||||
/// that the observer has accumulated.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlayerKnowledgeWire {
|
||||
/// Known entities with their knowledge metadata.
|
||||
pub entities: Vec<KnownEntityWire>,
|
||||
/// Known facts (non-entity knowledge: POIs, events, abstract info).
|
||||
pub facts: Vec<KnownFactWire>,
|
||||
}
|
||||
|
||||
/// A single entity knowledge entry for the journal wire format (#264).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownEntityWire {
|
||||
/// Wire-format entity ID.
|
||||
pub entity_id: u64,
|
||||
/// Display name (from known_attributes if available, else "Unknown").
|
||||
pub name: String,
|
||||
/// Confidence level (Suspects / KnowsOf / KnowsDetails / Direct).
|
||||
pub confidence: KnowledgeConfidence,
|
||||
/// How the knowledge was acquired.
|
||||
pub source: String,
|
||||
/// Logical state (Active / Contradicted / Stale).
|
||||
pub state: KnowledgeState,
|
||||
/// Relationship assessment for color rendering.
|
||||
pub relationship: RelationshipState,
|
||||
/// Last tick this entity was observed.
|
||||
pub last_observed_tick: u64,
|
||||
}
|
||||
|
||||
/// A single fact knowledge entry for the journal wire format (#264).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownFactWire {
|
||||
/// Fact identifier (e.g. "poi.docking_bay_7", "contraband.ring_exists").
|
||||
pub fact_id: String,
|
||||
/// Confidence level.
|
||||
pub confidence: KnowledgeConfidence,
|
||||
/// How the fact was acquired.
|
||||
pub source: String,
|
||||
/// Logical state.
|
||||
pub state: KnowledgeState,
|
||||
/// Tick when this fact was learned.
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
||||
// is authored, add weapon_proficiency + combat_style mapping here.
|
||||
|
||||
// Vision + awareness components (#115, #244) — must match generate_npc().
|
||||
// Without these, vision/awareness systems silently skip content-spawned NPCs.
|
||||
entity_commands.insert((
|
||||
npc::vision::NpcVisionState::default(),
|
||||
npc::vision::NpcMemory::default(),
|
||||
npc::awareness::PlayerAwareness::default(),
|
||||
));
|
||||
|
||||
let entity = entity_commands.id();
|
||||
|
||||
// Register in EntityRegistry for StableId mapping
|
||||
@@ -1100,6 +1108,49 @@ mod tests {
|
||||
assert_eq!(edge.trust, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn spawn_npc_combat_trained_sets_skill_flag() {
|
||||
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
|
||||
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
|
||||
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
|
||||
// path (generate.rs) correctly attaches CombatCapability. This test documents
|
||||
// current behavior so the gap is visible in CI.
|
||||
let mut world = create_test_world();
|
||||
let mut profile = create_test_profile();
|
||||
profile.skills = Some(NpcSkills {
|
||||
combat_trained: Some(true),
|
||||
skills: Some({
|
||||
let mut m = std::collections::BTreeMap::new();
|
||||
m.insert("combat".to_string(), 7);
|
||||
m
|
||||
}),
|
||||
});
|
||||
|
||||
let mut result = SpawnResult::default();
|
||||
spawn_npc(&mut world, &profile, &mut result);
|
||||
|
||||
let entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&result.npc_ids["test-npc"])
|
||||
.unwrap();
|
||||
|
||||
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
|
||||
let skills = world.get::<npc::SkillSet>(entity).unwrap();
|
||||
assert!(
|
||||
skills.combat_trained,
|
||||
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
|
||||
);
|
||||
|
||||
// Known gap: CombatCapability not yet attached in content-spawn path.
|
||||
// The procedural path (generate.rs) does attach it — content path has TODO.
|
||||
// Update this assertion when the TODO is resolved.
|
||||
assert!(
|
||||
world.get::<npc::CombatCapability>(entity).is_none(),
|
||||
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relationships_skips_unknown_targets() {
|
||||
let mut world = create_test_world();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! NPC player-awareness system (#244).
|
||||
//!
|
||||
//! Tracks how aware an NPC is of the player's attention. When the player
|
||||
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
|
||||
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
|
||||
//!
|
||||
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
|
||||
//! Separate from the follow mechanic (#241): follow tracks player-initiated
|
||||
//! proximity, awareness tracks NPC-perceived attention.
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! `detect_player_awareness` runs:
|
||||
//! - after `vision::compute_npc_vision` (needs player_visible)
|
||||
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
|
||||
//!
|
||||
//! ## Suspicion lifecycle
|
||||
//!
|
||||
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
|
||||
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
|
||||
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
|
||||
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
|
||||
//!
|
||||
//! All arithmetic is integer-only (D-010 determinism).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
|
||||
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
|
||||
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
|
||||
|
||||
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
|
||||
/// notices sustained player attention. Lighter than follow stress (2/tick)
|
||||
/// because watching is less intrusive than tailing.
|
||||
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
|
||||
|
||||
/// Ticks between suspicion decay checks when player is NOT in LOS.
|
||||
/// 10 ticks = 1 game-minute (D-031).
|
||||
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
|
||||
|
||||
/// Suspicion decay amount per interval when player is not in LOS.
|
||||
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks an NPC's awareness of sustained player attention (#244).
|
||||
///
|
||||
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
|
||||
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
|
||||
/// - `consecutive_los_ticks` — resets when player leaves LOS
|
||||
/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PlayerAwareness {
|
||||
/// Whether the player is currently in this NPC's line of sight.
|
||||
pub player_in_los: bool,
|
||||
/// Consecutive ticks the player has been in this NPC's LOS.
|
||||
pub consecutive_los_ticks: u64,
|
||||
/// Accumulated suspicion level (0–100, integer for D-010).
|
||||
pub suspicion_level: i16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect sustained player attention and build NPC suspicion.
|
||||
///
|
||||
/// For each Active-tier NPC with `PlayerAwareness`:
|
||||
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
|
||||
/// - If player visible: increment `consecutive_los_ticks`; once past
|
||||
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
|
||||
/// stress to `ToleranceThreshold`
|
||||
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
|
||||
/// `suspicion_level` periodically
|
||||
pub fn detect_player_awareness(
|
||||
time: Res<SimulationTime>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
&NpcVisionState,
|
||||
&mut PlayerAwareness,
|
||||
&mut ToleranceThreshold,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
|
||||
awareness.player_in_los = vision.player_visible;
|
||||
|
||||
if vision.player_visible {
|
||||
awareness.consecutive_los_ticks += 1;
|
||||
|
||||
// Once the NPC has noticed sustained player attention, build suspicion
|
||||
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
|
||||
tolerance.current_stress = tolerance
|
||||
.current_stress
|
||||
.saturating_add(AWARENESS_STRESS_PER_TICK);
|
||||
}
|
||||
} else {
|
||||
// Player left LOS — reset consecutive counter
|
||||
awareness.consecutive_los_ticks = 0;
|
||||
|
||||
// Decay suspicion slowly when player is not visible
|
||||
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
|
||||
awareness.suspicion_level =
|
||||
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::vision::NpcVisionState;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_player_awareness);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn default_tolerance() -> ToleranceThreshold {
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 80,
|
||||
}
|
||||
}
|
||||
|
||||
fn vision_with_player(visible: bool) -> NpcVisionState {
|
||||
NpcVisionState {
|
||||
player_visible: visible,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Basic LOS tracking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn player_in_los_increments_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_leaving_los_resets_consecutive_ticks() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: 20,
|
||||
suspicion_level: 5,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(!awareness.player_in_los);
|
||||
assert_eq!(awareness.consecutive_los_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_in_los_syncs_from_vision_state() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: false, // was false
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert!(awareness.player_in_los, "should sync from NpcVisionState");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion threshold — no stress before notice ticks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_stress_before_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not build before notice threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, 0,
|
||||
"stress should not increase before notice threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_starts_at_notice_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
|
||||
"consecutive ticks should reach threshold"
|
||||
);
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should increase at threshold"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
|
||||
"stress should increase at threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion accumulation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_accumulates_past_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 20,
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 + AWARENESS_STRESS_PER_TICK,
|
||||
"suspicion should accumulate"
|
||||
);
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
20 + AWARENESS_STRESS_PER_TICK,
|
||||
"stress should accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_caps_at_100() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
|
||||
suspicion_level: 100,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_stress_saturates() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
|
||||
suspicion_level: 50,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: i16::MAX - 1,
|
||||
threshold: i16::MAX,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
i16::MAX,
|
||||
"stress should saturate, not overflow"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Suspicion decay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suspicion_decays_when_player_not_in_los_on_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a decay interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level,
|
||||
10 - AWARENESS_DECAY_AMOUNT,
|
||||
"suspicion should decay on interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_decay_off_interval() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to a non-interval boundary
|
||||
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 10,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 10,
|
||||
"suspicion should not decay on non-interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspicion_does_not_go_below_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(false),
|
||||
PlayerAwareness {
|
||||
player_in_los: false,
|
||||
consecutive_los_ticks: 0,
|
||||
suspicion_level: 0,
|
||||
},
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.suspicion_level, 0,
|
||||
"suspicion should not go below zero"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Background NPC not processed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn background_npc_not_processed() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::BackgroundSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness::default(),
|
||||
default_tolerance(),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
|
||||
assert_eq!(
|
||||
awareness.consecutive_los_ticks, 0,
|
||||
"background NPC should not have awareness processed"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No panic with missing components
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_without_awareness_no_panic() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC with vision but no PlayerAwareness — system should skip
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
default_tolerance(),
|
||||
));
|
||||
|
||||
run_system(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Integration: follow + awareness stress stacking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_stress_stacks_with_existing_stress() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC already has stress from other sources (e.g., follow mechanic)
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
vision_with_player(true),
|
||||
PlayerAwareness {
|
||||
player_in_los: true,
|
||||
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
|
||||
suspicion_level: 5,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: 30, // pre-existing stress
|
||||
threshold: 80,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
|
||||
assert_eq!(
|
||||
tolerance.current_stress,
|
||||
30 + AWARENESS_STRESS_PER_TICK,
|
||||
"awareness stress should stack with existing stress"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant value assertions (#244 spec compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn awareness_constants_have_expected_values() {
|
||||
assert_eq!(
|
||||
AWARENESS_NOTICE_TICKS, 30,
|
||||
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_STRESS_PER_TICK, 1,
|
||||
"#244: stress per tick lighter than follow stress (D-010 integer)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_INTERVAL, 10,
|
||||
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
|
||||
);
|
||||
assert_eq!(
|
||||
AWARENESS_DECAY_AMOUNT, 1,
|
||||
"#244: suspicion decays by 1 per interval"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
//! Background tier state machines (#95, D-026).
|
||||
//!
|
||||
//! Four lightweight state machines for Background-tier NPCs, firing once per
|
||||
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
|
||||
//!
|
||||
//! ## State machines
|
||||
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
|
||||
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
|
||||
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
|
||||
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
|
||||
//!
|
||||
//! ## Design constraints (D-026)
|
||||
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
|
||||
//! - All arithmetic is integer-only (D-010 determinism requirement).
|
||||
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::tier::BackgroundSim;
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mood derivation (background-tier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive simplified mood for a Background-tier NPC.
|
||||
///
|
||||
/// No phase or warm-flag considerations — background NPCs have no active
|
||||
/// interactions. Priority order:
|
||||
/// 1. Hostile — stress ≥ threshold
|
||||
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
|
||||
/// 3. Content — stress < 20
|
||||
/// 4. Neutral — otherwise
|
||||
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
|
||||
// 1. Hostile: at or above threshold
|
||||
if current_stress >= threshold {
|
||||
return NpcMood::Hostile;
|
||||
}
|
||||
|
||||
// 2. Anxious: 60% of threshold reached
|
||||
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
|
||||
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
|
||||
return NpcMood::Anxious;
|
||||
}
|
||||
|
||||
// 3. Content: low stress
|
||||
if current_stress < 20 {
|
||||
return NpcMood::Content;
|
||||
}
|
||||
|
||||
// 4. Neutral: default
|
||||
NpcMood::Neutral
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drift job performance score one point per game-minute based on contentment.
|
||||
///
|
||||
/// - contentment > 0 → score + 1 (clamped at 100)
|
||||
/// - contentment < 0 → score - 1 (clamped at 0)
|
||||
/// - contentment = 0 → unchanged
|
||||
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
|
||||
match contentment_level.cmp(&0) {
|
||||
std::cmp::Ordering::Greater => (score + 1).min(100),
|
||||
std::cmp::Ordering::Less => (score - 1).max(0),
|
||||
std::cmp::Ordering::Equal => score,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: background_tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: run all four background-tier state machines once per game-minute.
|
||||
///
|
||||
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
|
||||
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
|
||||
/// per-tick systems.
|
||||
///
|
||||
/// Machine execution order per NPC:
|
||||
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
|
||||
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
|
||||
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
|
||||
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
|
||||
///
|
||||
/// Commands for `ActivityState` are deferred (applied after system runs).
|
||||
/// Mutable component mutations happen immediately within the iteration.
|
||||
pub fn background_tick(
|
||||
time: Res<SimulationTime>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
Option<&ActivityState>,
|
||||
&mut MoodState,
|
||||
Option<&mut Relationships>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&DailyRoutine>,
|
||||
Option<&Contentment>,
|
||||
Option<&mut JobPerformance>,
|
||||
),
|
||||
(With<Npc>, With<BackgroundSim>),
|
||||
>,
|
||||
) {
|
||||
// Fire once per game-minute (D-031: 10 ticks/minute)
|
||||
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let phase = time.day_phase();
|
||||
let tick = time.tick;
|
||||
|
||||
for (
|
||||
entity,
|
||||
activity_opt,
|
||||
mut mood_state,
|
||||
rels_opt,
|
||||
tolerance_opt,
|
||||
routine_opt,
|
||||
contentment_opt,
|
||||
job_opt,
|
||||
) in query.iter_mut()
|
||||
{
|
||||
// --- 1. Schedule: sync ActivityState to current DayPhase ---
|
||||
// Background NPCs don't pathfind — we directly declare the activity.
|
||||
if let Some(routine) = routine_opt {
|
||||
if let Some(entry) = routine.entry_for_phase(phase) {
|
||||
let needs_update = match activity_opt {
|
||||
Some(a) => a.phase != phase || a.activity != entry.activity,
|
||||
None => true,
|
||||
};
|
||||
if needs_update {
|
||||
commands.entity(entity).insert(ActivityState {
|
||||
activity: entry.activity.clone(),
|
||||
phase,
|
||||
started_tick: tick,
|
||||
});
|
||||
}
|
||||
} else if activity_opt.is_some() {
|
||||
// No routine entry for this phase — clear stale activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Mood: simplified stress-based derivation ---
|
||||
let (stress, threshold) = tolerance_opt
|
||||
.map(|t| (t.current_stress, t.threshold))
|
||||
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
|
||||
let new_mood = derive_background_mood(stress, threshold);
|
||||
if mood_state.mood != new_mood {
|
||||
mood_state.mood = new_mood;
|
||||
mood_state.changed_tick = tick;
|
||||
}
|
||||
|
||||
// --- 3. Relationships: trust drift toward 0 (baseline) ---
|
||||
if let Some(mut rels) = rels_opt {
|
||||
for rel in &mut rels.entries {
|
||||
if rel.trust_level > 0 {
|
||||
rel.trust_level -= 1;
|
||||
} else if rel.trust_level < 0 {
|
||||
rel.trust_level += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Job: performance drift from contentment ---
|
||||
if let Some(mut job) = job_opt {
|
||||
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
|
||||
job.score = drift_job_performance(job.score, contentment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::{
|
||||
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, ToleranceThreshold,
|
||||
};
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::routine::ActivityState;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
use crate::knowledge::types::StableId;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
// --- derive_background_mood ---
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_at_threshold() {
|
||||
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_hostile_above_threshold() {
|
||||
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_at_60_percent() {
|
||||
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
|
||||
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_anxious_boundary_below_threshold() {
|
||||
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
|
||||
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_content_low_stress() {
|
||||
// stress=10 < 20 → Content (not hostile, not anxious)
|
||||
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_neutral_moderate_stress() {
|
||||
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
|
||||
// not content (25 >= 20) → Neutral
|
||||
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_threshold_is_hostile() {
|
||||
// stress=0 >= threshold=0 → Hostile
|
||||
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_mood_zero_stress_moderate_threshold_is_content() {
|
||||
// stress=0 < 20 → Content
|
||||
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
|
||||
}
|
||||
|
||||
// --- drift_job_performance ---
|
||||
|
||||
#[test]
|
||||
fn job_drift_up_when_positive_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 10), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_down_when_negative_contentment() {
|
||||
assert_eq!(drift_job_performance(50, -10), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_unchanged_at_zero_contentment() {
|
||||
assert_eq!(drift_job_performance(50, 0), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_100() {
|
||||
assert_eq!(drift_job_performance(100, 5), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_drift_clamps_at_0() {
|
||||
assert_eq!(drift_job_performance(0, -5), 0);
|
||||
}
|
||||
|
||||
// --- background_tick system integration tests ---
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(background_tick);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase,
|
||||
location: TilePosition::new(5, 5, 0),
|
||||
activity: activity.to_string(),
|
||||
}],
|
||||
description: "Test routine".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tick gating ---
|
||||
|
||||
#[test]
|
||||
fn does_not_fire_on_non_minute_tick() {
|
||||
let mut world = setup_world();
|
||||
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// Mood should NOT have updated (Hostile would fire if it ran)
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_zero() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 is 0 % 10 == 0, so it fires
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
// --- Active-tier NPCs not processed ---
|
||||
|
||||
#[test]
|
||||
fn active_npcs_not_processed() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// ActiveSim NPC — must NOT be processed by background_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState {
|
||||
mood: NpcMood::Warm,
|
||||
changed_tick: 0,
|
||||
},
|
||||
ToleranceThreshold { current_stress: 60, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
|
||||
}
|
||||
|
||||
// --- Mood state machine ---
|
||||
|
||||
#[test]
|
||||
fn mood_hostile_when_stress_at_threshold() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 50, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_when_no_tolerance() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
|
||||
let npc = world
|
||||
.spawn((Npc, BackgroundSim, MoodState::default()))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_records_changed_tick() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Hostile);
|
||||
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_unchanged_tick_not_updated() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// Already Content, will derive Content → no change to changed_tick
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState { mood: NpcMood::Content, changed_tick: 42 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mood = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood.mood, NpcMood::Content);
|
||||
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
|
||||
}
|
||||
|
||||
// --- Schedule state machine ---
|
||||
|
||||
#[test]
|
||||
fn schedule_sets_activity_state_for_current_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → DayPhase::Morning
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
assert_eq!(activity.phase, DayPhase::Morning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_no_activity_when_no_routine_entry_for_phase() {
|
||||
let mut world = setup_world();
|
||||
// tick=0 → Morning, but routine only has Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Afternoon, "Meeting"),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
// No ActivityState should be inserted (Morning has no entry)
|
||||
assert!(world.get::<ActivityState>(npc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_preserves_correct_activity_state() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0; // Morning
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
make_routine(DayPhase::Morning, "Work"),
|
||||
// Already has correct ActivityState — should not be re-inserted
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Morning,
|
||||
started_tick: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let activity = world.get::<ActivityState>(npc).unwrap();
|
||||
assert_eq!(activity.activity, "Work");
|
||||
}
|
||||
|
||||
// --- Relationships state machine ---
|
||||
|
||||
#[test]
|
||||
fn relationships_positive_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(1),
|
||||
kind: RelationshipKind::Friend,
|
||||
trust_level: 5,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_negative_trust_drifts_toward_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(2),
|
||||
kind: RelationshipKind::Rival,
|
||||
trust_level: -4,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_zero_trust_stays_zero() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
Relationships {
|
||||
entries: vec![Relationship {
|
||||
target_id: StableId(3),
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust_level: 0,
|
||||
history: vec![],
|
||||
}],
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let rels = world.get::<Relationships>(npc).unwrap();
|
||||
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
|
||||
}
|
||||
|
||||
// --- Job state machine ---
|
||||
|
||||
#[test]
|
||||
fn job_performance_rises_with_positive_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: 20 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 61);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_falls_with_negative_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 60 },
|
||||
Contentment { level: -15 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_unchanged_at_zero_contentment() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
JobPerformance { score: 50 },
|
||||
Contentment { level: 0 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let job = world.get::<JobPerformance>(npc).unwrap();
|
||||
assert_eq!(job.score, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_performance_without_component_no_panic() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
// No JobPerformance — system must not panic
|
||||
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
|
||||
|
||||
run_system(&mut world); // must not panic
|
||||
}
|
||||
|
||||
// --- Multiple NPCs independent ---
|
||||
|
||||
#[test]
|
||||
fn multiple_background_npcs_processed_independently() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 0;
|
||||
|
||||
let calm = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 5, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
let hostile = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState::default(),
|
||||
ToleranceThreshold { current_stress: 55, threshold: 50 },
|
||||
))
|
||||
.id();
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
|
||||
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
||||
use crate::npc::{
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
|
||||
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
|
||||
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
|
||||
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
|
||||
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
|
||||
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
|
||||
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
|
||||
};
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::npc::mood::MoodState;
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
@@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
|
||||
|
||||
DailyRoutine {
|
||||
entries,
|
||||
description: format!("Routine schedule"),
|
||||
description: "Routine schedule".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
|
||||
tells,
|
||||
skills,
|
||||
MoodState::default(),
|
||||
JobPerformance::default(),
|
||||
));
|
||||
|
||||
// Vision + knowledge + awareness components (#115, #244, D-041).
|
||||
// Separate insert to stay within tuple bundle element limit.
|
||||
entity_builder.insert((
|
||||
KnowledgeGraph::new(),
|
||||
NpcVisionState::default(),
|
||||
NpcMemory::default(),
|
||||
PlayerAwareness::default(),
|
||||
));
|
||||
|
||||
if let Some(cap) = combat_opt {
|
||||
@@ -876,6 +889,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Combat capability — positive case (#91, D-024)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn combat_role_probabilistically_produces_combat_capability() {
|
||||
// #91 positive-case: at least one seed must produce CombatCapability for
|
||||
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
|
||||
// so 30 seeds is overwhelmingly likely to produce at least one match.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
let mut any_combat = false;
|
||||
for seed in 0..30_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if world.get::<CombatCapability>(entity).is_some() {
|
||||
any_combat = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
any_combat,
|
||||
"combat-enabled role must produce CombatCapability for at least one seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_trained_flag_matches_combat_capability_presence() {
|
||||
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
|
||||
// If CombatCapability is present, combat_trained must be true and vice versa.
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..50_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
let skills = world.get::<SkillSet>(entity).unwrap();
|
||||
let has_cap = world.get::<CombatCapability>(entity).is_some();
|
||||
assert_eq!(
|
||||
skills.combat_trained, has_cap,
|
||||
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
|
||||
skills.combat_trained, has_cap
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_capability_proficiency_in_valid_range() {
|
||||
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
|
||||
let role = minimal_role(); // combat_enabled = true
|
||||
for seed in 0..100_u64 {
|
||||
let mut world = World::new();
|
||||
let mut rng = make_rng(seed);
|
||||
let entity = generate_npc(&role, &mut world, &mut rng);
|
||||
if let Some(cap) = world.get::<CombatCapability>(entity) {
|
||||
assert!(
|
||||
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
|
||||
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
|
||||
cap.weapon_proficiency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ActiveSim tier
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod awareness;
|
||||
pub mod background;
|
||||
pub mod disclosure;
|
||||
pub mod generate;
|
||||
pub mod interaction;
|
||||
@@ -11,6 +13,7 @@ pub mod routine;
|
||||
pub mod tell_state;
|
||||
pub mod tolerance;
|
||||
pub mod trait_modifiers;
|
||||
pub mod vision;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<relationships::TrustEventQueue>()
|
||||
.init_resource::<relationships::PropagationQueue>()
|
||||
.init_resource::<relationships::DelayedTrustQueue>()
|
||||
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
||||
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.after(crate::simulation::dialogue::process_dialogue_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::propagate_social_actions
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::update_relationship_dynamics
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
|
||||
.after(mood::update_mood)
|
||||
.after(routine::detect_routine_deviation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
background::background_tick
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
disclosure::derive_disclosure_candidates
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
disclosure::process_unprompted_disclosure
|
||||
.after(disclosure::derive_disclosure_candidates)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// NPC player-awareness (#244)
|
||||
awareness::detect_player_awareness
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(tolerance::check_tolerance_threshold),
|
||||
// NPC vision system (#115, D-011)
|
||||
vision::compute_npc_vision
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.after(crate::simulation::tier::update_tier_markers)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
vision::emit_npc_vision_events
|
||||
.after(vision::compute_npc_vision)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
vision::degrade_npc_inferences
|
||||
.after(vision::emit_npc_vision_events)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
@@ -268,6 +294,30 @@ pub struct Contentment {
|
||||
pub level: i16, // -100..+100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job performance (D-026 background state machine — feeds from Contentment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks how well an NPC performs their job role.
|
||||
///
|
||||
/// Drifts based on `Contentment` level in the background-tier state machine:
|
||||
/// - contentment > 0 → score increases toward 100
|
||||
/// - contentment < 0 → score decreases toward 0
|
||||
/// - contentment = 0 → no change
|
||||
///
|
||||
/// Updated by `background::background_tick` once per game-minute for
|
||||
/// Background-tier NPCs. Persists through tier promotions (D-026).
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobPerformance {
|
||||
pub score: i16, // 0..=100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
impl Default for JobPerformance {
|
||||
fn default() -> Self {
|
||||
Self { score: 50 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 1: Personality traits (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,7 +141,8 @@ impl RelationshipGraph {
|
||||
}
|
||||
|
||||
/// Get all entities who have feelings about a target.
|
||||
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
|
||||
/// O(N) full scan of all edges. Called once per game-minute (every 10 ticks)
|
||||
/// by the pressure system — acceptable at v0.1 NPC counts.
|
||||
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
self.edges
|
||||
.iter()
|
||||
@@ -201,16 +202,121 @@ impl RelationshipGraph {
|
||||
// System: update_trust (#324)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social propagation (#249, D-029)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum absolute trust required on a relationship edge for that edge
|
||||
/// to carry propagation (D-029: "strong relationships", trust > 3).
|
||||
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
|
||||
|
||||
/// Delay in ticks before third-order propagation is applied.
|
||||
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
|
||||
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
|
||||
|
||||
/// A first-order trust change that needs social propagation.
|
||||
///
|
||||
/// Produced by `update_trust` for each processed event, consumed by
|
||||
/// `propagate_social_actions` to fan out second- and third-order effects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PropagationEvent {
|
||||
/// The NPC directly affected by the player action (first-order subject).
|
||||
pub npc: StableId,
|
||||
/// The player entity (target of how NPCs feel).
|
||||
pub player: StableId,
|
||||
/// The first-order delta (same as what was applied to the graph).
|
||||
pub delta: i8,
|
||||
}
|
||||
|
||||
/// Queue of propagation events emitted by `update_trust`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PropagationQueue {
|
||||
events: Vec<PropagationEvent>,
|
||||
}
|
||||
|
||||
impl PropagationQueue {
|
||||
pub fn push(&mut self, event: PropagationEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<PropagationEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
}
|
||||
|
||||
/// A deferred trust change for third-order propagation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DelayedTrustChange {
|
||||
/// NPC whose trust toward the player will be adjusted.
|
||||
pub npc: StableId,
|
||||
/// Player entity.
|
||||
pub player: StableId,
|
||||
/// Scaled delta to apply (already clamped to meaningful range).
|
||||
pub delta: i8,
|
||||
/// Tick at which this change should be applied.
|
||||
pub apply_at_tick: u64,
|
||||
}
|
||||
|
||||
/// Queue of deferred third-order trust changes.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct DelayedTrustQueue {
|
||||
pending: Vec<DelayedTrustChange>,
|
||||
}
|
||||
|
||||
impl DelayedTrustQueue {
|
||||
pub fn push(&mut self, change: DelayedTrustChange) {
|
||||
self.pending.push(change);
|
||||
}
|
||||
|
||||
/// Drain changes that are due at or before `current_tick`.
|
||||
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
|
||||
let mut due = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
for change in self.pending.drain(..) {
|
||||
if change.apply_at_tick <= current_tick {
|
||||
due.push(change);
|
||||
} else {
|
||||
remaining.push(change);
|
||||
}
|
||||
}
|
||||
self.pending = remaining;
|
||||
due
|
||||
}
|
||||
|
||||
/// Number of pending deferred changes.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a first-order delta by a propagation factor, using integer arithmetic
|
||||
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
||||
/// round to zero — small deltas naturally attenuate to nothing.
|
||||
///
|
||||
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
|
||||
///
|
||||
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
||||
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
||||
// Multiply by factor, round up (ceiling of absolute value)
|
||||
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
|
||||
let scaled = scaled_abs.min(10) as i8;
|
||||
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
|
||||
}
|
||||
|
||||
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
||||
///
|
||||
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
||||
/// one is created with default Colleague kind and trust 0 before applying
|
||||
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
||||
///
|
||||
/// Also queues a `PropagationEvent` for each processed event so that
|
||||
/// `propagate_social_actions` can fan out second- and third-order effects.
|
||||
///
|
||||
/// System ordering: after dialogue systems (which emit the events),
|
||||
/// before advance_tick.
|
||||
pub fn update_trust(
|
||||
mut queue: ResMut<TrustEventQueue>,
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
registry: Res<EntityRegistry>,
|
||||
time: Res<SimulationTime>,
|
||||
@@ -244,6 +350,116 @@ pub fn update_trust(
|
||||
new_trust = edge.trust,
|
||||
"Trust updated"
|
||||
);
|
||||
|
||||
// Queue propagation event for second/third-order effects (#249)
|
||||
propagation_queue.push(PropagationEvent {
|
||||
npc: npc_sid,
|
||||
player: player_sid,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// System: fan out player-action trust changes through the social graph (#249, D-029).
|
||||
///
|
||||
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
|
||||
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
|
||||
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
|
||||
///
|
||||
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
|
||||
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
|
||||
/// the `RelationshipGraph` is seeded differently per run.
|
||||
///
|
||||
/// System ordering: after `update_trust`, before `advance_tick`.
|
||||
pub fn propagate_social_actions(
|
||||
mut propagation_queue: ResMut<PropagationQueue>,
|
||||
mut delay_queue: ResMut<DelayedTrustQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
time: Res<SimulationTime>,
|
||||
) {
|
||||
// --- Apply any due delayed third-order changes first ---
|
||||
for change in delay_queue.drain_due(time.tick) {
|
||||
if change.delta != 0 {
|
||||
graph.ensure_edge(change.npc, change.player, time.tick);
|
||||
graph.adjust_trust(&change.npc, &change.player, change.delta);
|
||||
tracing::trace!(
|
||||
npc = change.npc.0,
|
||||
player = change.player.0,
|
||||
delta = change.delta,
|
||||
"Third-order trust propagated (delayed)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fan out new propagation events ---
|
||||
for event in propagation_queue.drain() {
|
||||
let PropagationEvent { npc, player, delta } = event;
|
||||
|
||||
// Visited set prevents cycles (D-029)
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
visited.insert(npc);
|
||||
|
||||
// --- Second-order (immediate, 40% of delta) ---
|
||||
let second_delta = scale_delta(delta, 4); // 40%
|
||||
if second_delta != 0 {
|
||||
// Find NPCs that the first-order NPC trusts strongly
|
||||
let second_order: Vec<StableId> = graph
|
||||
.relationships_of(&npc)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for second in &second_order {
|
||||
if visited.contains(second) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(*second);
|
||||
graph.ensure_edge(*second, player, time.tick);
|
||||
graph.adjust_trust(second, &player, second_delta);
|
||||
tracing::trace!(
|
||||
first_order_npc = npc.0,
|
||||
second_order_npc = second.0,
|
||||
player = player.0,
|
||||
delta = second_delta,
|
||||
"Second-order trust propagated"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Third-order (delayed 30 ticks, 15% of delta) ---
|
||||
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
|
||||
if third_delta != 0 {
|
||||
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
|
||||
for second in &second_order {
|
||||
let third_order: Vec<StableId> = graph
|
||||
.relationships_of(second)
|
||||
.into_iter()
|
||||
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
|
||||
.map(|(target, _)| *target)
|
||||
.collect();
|
||||
|
||||
for third in third_order {
|
||||
if visited.contains(&third) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(third);
|
||||
delay_queue.push(DelayedTrustChange {
|
||||
npc: third,
|
||||
player,
|
||||
delta: third_delta,
|
||||
apply_at_tick: apply_at,
|
||||
});
|
||||
tracing::trace!(
|
||||
third_order_npc = third.0,
|
||||
player = player.0,
|
||||
delta = third_delta,
|
||||
apply_at,
|
||||
"Third-order trust queued for delayed propagation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +709,7 @@ mod tests {
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -748,6 +965,380 @@ mod tests {
|
||||
assert_eq!(edge.trust, 6); // 5 + 1
|
||||
}
|
||||
|
||||
// -- PropagationQueue tests (#249) ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn propagation_queue_push_and_drain() {
|
||||
let mut q = PropagationQueue::default();
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(1),
|
||||
player: StableId(2),
|
||||
delta: 1,
|
||||
});
|
||||
q.push(PropagationEvent {
|
||||
npc: StableId(3),
|
||||
player: StableId(2),
|
||||
delta: -2,
|
||||
});
|
||||
let drained = q.drain();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(q.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_trust_queue_drain_due_filters_by_tick() {
|
||||
let mut q = DelayedTrustQueue::default();
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(1),
|
||||
player: StableId(10),
|
||||
delta: 1,
|
||||
apply_at_tick: 50,
|
||||
});
|
||||
q.push(DelayedTrustChange {
|
||||
npc: StableId(2),
|
||||
player: StableId(10),
|
||||
delta: -1,
|
||||
apply_at_tick: 100,
|
||||
});
|
||||
|
||||
// Only change at tick 50 is due at tick 60
|
||||
let due = q.drain_due(60);
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].npc, StableId(1));
|
||||
// Change at tick 100 is still pending
|
||||
assert_eq!(q.pending_count(), 1);
|
||||
|
||||
// At tick 100 it becomes due
|
||||
let due2 = q.drain_due(100);
|
||||
assert_eq!(due2.len(), 1);
|
||||
assert_eq!(due2[0].npc, StableId(2));
|
||||
assert_eq!(q.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_forty_percent() {
|
||||
// factor_tenths=4 → 40%
|
||||
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
|
||||
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
|
||||
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
|
||||
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
|
||||
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_delta_zero_when_too_small() {
|
||||
// delta=0 → always 0
|
||||
assert_eq!(scale_delta(0, 4), 0);
|
||||
}
|
||||
|
||||
// -- propagate_social_actions system tests (#249) -------------------------
|
||||
|
||||
fn setup_propagation_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<PropagationQueue>();
|
||||
world.init_resource::<DelayedTrustQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_order_trust_propagates_immediately() {
|
||||
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
|
||||
// receive 40% of the delta in the same tick.
|
||||
//
|
||||
// Graph: A → B (trust 5, > threshold 3)
|
||||
// A → player (will be first-order)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// Expected: B gains 40% of 2 = 1 (ceil) toward player
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A strongly trusts B (A→B trust=5)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
// Queue propagation from A
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should now have a trust edge toward the player (positive)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust > 0,
|
||||
"Second-order NPC should trust player more after positive first-order event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_relationship_does_not_propagate() {
|
||||
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
|
||||
//
|
||||
// Graph: A → B (trust 2, ≤ threshold 3)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: B gets no propagation (trust ≤ threshold)
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// A weakly trusts B (below threshold)
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Colleague, 2),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should NOT have any edge to the player
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_none(),
|
||||
"Weak relationship should not carry propagation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_order_trust_is_deferred_by_thirty_ticks() {
|
||||
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
|
||||
//
|
||||
// Graph: A → B (trust 5), B → C (trust 5)
|
||||
// Event: player action affects A (delta=+5)
|
||||
// Expected: C's change is queued for tick+30, not applied immediately
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should NOT have an edge yet (it's deferred)
|
||||
{
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(
|
||||
graph.get_relationship(&npc_c, &player).is_none(),
|
||||
"Third-order changes should be deferred, not applied immediately"
|
||||
);
|
||||
}
|
||||
|
||||
// Delay queue should have one pending change for C at tick 10+30=40
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_changes_applied_when_due() {
|
||||
// Once the tick advances past apply_at_tick, the delayed change is applied.
|
||||
let mut world = setup_propagation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let npc_c = StableId(3);
|
||||
let player = StableId(99);
|
||||
|
||||
let mut graph = world.resource_mut::<RelationshipGraph>();
|
||||
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
// First run: queue the third-order change
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Advance tick to 40 (past apply_at_tick = 40)
|
||||
world.resource_mut::<SimulationTime>().tick = 40;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// C should now have an edge with positive trust
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_c, &player)
|
||||
.expect("Delayed change should have been applied by now");
|
||||
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_prevention_no_a_to_b_to_a_loop() {
|
||||
// Spec (#249, D-029): visited set prevents A→B→A cycles.
|
||||
//
|
||||
// Graph: A ↔ B (both trust each other at 5)
|
||||
// Event: player action affects A (delta=+2)
|
||||
// A should propagate to B, but B should NOT propagate back to A.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
// Bidirectional strong trust
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_b,
|
||||
npc_a,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// A should not get a second-order change from the cycle (A was the origin)
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
// The only A→player edge effect should be through the propagation event
|
||||
// (no direct creation in propagate_social_actions — only update_trust does that).
|
||||
// B should have an edge to player.
|
||||
assert!(
|
||||
graph.get_relationship(&npc_b, &player).is_some(),
|
||||
"B should get second-order propagation from A"
|
||||
);
|
||||
// A should NOT have a re-propagated edge (cycle prevented)
|
||||
assert!(
|
||||
graph.get_relationship(&npc_a, &player).is_none(),
|
||||
"A should not receive back-propagation from B (cycle prevention)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_delta_propagates_as_negative() {
|
||||
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
|
||||
// with the same sign.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let npc_b = StableId(2);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_a,
|
||||
npc_b,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: -2, // confrontation
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// B should trust player LESS after A was confronted
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_b, &player)
|
||||
.expect("B should have edge to player");
|
||||
assert!(
|
||||
edge.trust < 0,
|
||||
"Negative propagation should reduce B's trust in player"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_relationships_means_no_propagation() {
|
||||
// If the first-order NPC has no relationships, the queue is drained
|
||||
// but nothing propagates.
|
||||
let mut world = setup_propagation_world();
|
||||
|
||||
let npc_a = StableId(1);
|
||||
let player = StableId(99);
|
||||
|
||||
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
|
||||
npc: npc_a,
|
||||
player,
|
||||
delta: 5,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(propagate_social_actions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(graph.is_empty(), "No relationships → no propagation edges created");
|
||||
|
||||
let delay_queue = world.resource::<DelayedTrustQueue>();
|
||||
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_trust_populates_propagation_queue() {
|
||||
// Integration: update_trust should push to PropagationQueue for downstream #249.
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// PropagationQueue should have 1 event (for downstream propagation)
|
||||
let prop_events = world.resource_mut::<PropagationQueue>().drain();
|
||||
assert_eq!(prop_events.len(), 1);
|
||||
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_entity_event_is_skipped() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
//! NPC vision system (#115, D-011).
|
||||
//!
|
||||
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
|
||||
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
|
||||
//! positions and zone inferences ("saw you enter building → knows you're
|
||||
//! inside").
|
||||
//!
|
||||
//! ## System ordering
|
||||
//!
|
||||
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
|
||||
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
|
||||
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
|
||||
//!
|
||||
//! ## Performance
|
||||
//!
|
||||
//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within
|
||||
//! D-026 Active tier budget by architecture review.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::knowledge::{
|
||||
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
|
||||
};
|
||||
use crate::npc::Npc;
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
|
||||
pub const NPC_VISION_RANGE: i32 = 20;
|
||||
|
||||
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
|
||||
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current field-of-view results for an NPC.
|
||||
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct NpcVisionState {
|
||||
/// StableIds of entities currently in this NPC's LOS.
|
||||
pub visible_entities: BTreeSet<StableId>,
|
||||
/// Whether the player character is currently visible.
|
||||
pub player_visible: bool,
|
||||
}
|
||||
|
||||
/// Persistent memory of entities this NPC has seen.
|
||||
/// Survives after entities leave LOS (D-011: "saw you enter building →
|
||||
/// knows you're inside").
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NpcMemory {
|
||||
/// Last known position + tick for entities this NPC has seen.
|
||||
/// Key: StableId of the observed entity.
|
||||
pub last_known: BTreeMap<StableId, LastKnownEntry>,
|
||||
}
|
||||
|
||||
/// Record of last-known position for a single entity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastKnownEntry {
|
||||
/// Position where entity was last seen.
|
||||
pub position: TilePosition,
|
||||
/// Tick when the entity was last observed.
|
||||
pub observed_tick: u64,
|
||||
/// Zone inference: if entity was last seen before leaving LOS,
|
||||
/// the NPC infers they are still nearby.
|
||||
pub zone_inference: Option<ZoneInference>,
|
||||
}
|
||||
|
||||
/// Inference that an entity is still near a position based on last observation.
|
||||
/// Degrades after `degrades_at_tick`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZoneInference {
|
||||
/// The position where the entity was last seen.
|
||||
pub last_seen_position: TilePosition,
|
||||
/// Tick when the entity was seen at this position.
|
||||
pub observed_tick: u64,
|
||||
/// Tick at which this inference degrades (NPC stops assuming entity is here).
|
||||
pub degrades_at_tick: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute NPC field-of-view for all Active-tier NPCs.
|
||||
///
|
||||
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
|
||||
/// apply vision cone if the NPC has a `Facing` component, then check which entities
|
||||
/// from the spatial index are at visible tiles.
|
||||
pub fn compute_npc_vision(
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
spatial_index: Res<NaiveSpatialIndex>,
|
||||
player_query: Query<Entity, With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
) {
|
||||
let Some(walkability) = walkability else {
|
||||
return;
|
||||
};
|
||||
let player_entity = player_query.iter().next();
|
||||
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
|
||||
let config = VisionConeConfig::default();
|
||||
|
||||
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
|
||||
let z = npc_pos.z;
|
||||
|
||||
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
|
||||
let fov = compute_fov(
|
||||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||
npc_pos.x,
|
||||
npc_pos.y,
|
||||
NPC_VISION_RANGE,
|
||||
z,
|
||||
);
|
||||
|
||||
// Collect visible tile positions — apply vision cone if NPC has facing
|
||||
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
|
||||
let cone_tiles =
|
||||
apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
|
||||
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
|
||||
} else {
|
||||
// No facing → omnidirectional vision (full FOV)
|
||||
fov.visible_tiles().collect()
|
||||
};
|
||||
|
||||
// Find entities at visible positions via spatial index
|
||||
let mut new_visible = BTreeSet::new();
|
||||
let mut player_vis = false;
|
||||
|
||||
// Single-pass query: all entities within vision range including own tile
|
||||
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
|
||||
|
||||
for entity in candidates {
|
||||
if entity == npc_entity {
|
||||
continue;
|
||||
}
|
||||
let Ok(entity_pos) = entity_positions.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
if entity_pos.z != z {
|
||||
continue;
|
||||
}
|
||||
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
|
||||
continue;
|
||||
}
|
||||
let Some(stable_id) = registry.to_stable(entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
new_visible.insert(stable_id);
|
||||
if Some(stable_id) == player_stable_id {
|
||||
player_vis = true;
|
||||
}
|
||||
}
|
||||
|
||||
vision_state.visible_entities = new_visible;
|
||||
vision_state.player_visible = player_vis;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit knowledge events when NPCs gain or lose sight of the player.
|
||||
///
|
||||
/// Mirrors the player's `emit_observation_events` pattern but scoped to
|
||||
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
|
||||
/// for direct query by downstream systems (#244 awareness) without flooding
|
||||
/// the knowledge event queue.
|
||||
///
|
||||
/// Also updates `NpcMemory` with last-known positions and zone inferences.
|
||||
pub fn emit_npc_vision_events(
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
mut npc_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&NpcVisionState,
|
||||
&mut NpcMemory,
|
||||
Option<&KnowledgeGraph>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
|
||||
if vision_state.player_visible {
|
||||
// Player is in LOS — update memory and emit DirectObservation
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: *player_pos,
|
||||
observed_tick: time.tick,
|
||||
zone_inference: None, // Active observation clears inference
|
||||
},
|
||||
);
|
||||
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: npc_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target: player_entity,
|
||||
position: *player_pos,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Player NOT in LOS — check if they WERE Direct (just left)
|
||||
let was_direct = knowledge_graph
|
||||
.and_then(|kg| {
|
||||
kg.entity_knowledge(&player_sid)
|
||||
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if was_direct {
|
||||
// Player just left this NPC's LOS — emit LeftLOS
|
||||
event_queue.push(KnowledgeEvent {
|
||||
observer: npc_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::LeftLOS {
|
||||
target: player_entity,
|
||||
},
|
||||
});
|
||||
|
||||
// Create zone inference — NPC remembers where they last saw the player
|
||||
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
|
||||
entry.zone_inference = Some(ZoneInference {
|
||||
last_seen_position: entry.position,
|
||||
observed_tick: entry.observed_tick,
|
||||
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Degrade stale zone inferences in NPC memory.
|
||||
///
|
||||
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
|
||||
/// inference passes its degradation tick, the inference is removed.
|
||||
pub fn degrade_npc_inferences(
|
||||
time: Res<SimulationTime>,
|
||||
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
||||
) {
|
||||
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for mut memory in npc_query.iter_mut() {
|
||||
for entry in memory.last_known.values_mut() {
|
||||
if let Some(ref inference) = entry.zone_inference {
|
||||
if time.tick >= inference.degrades_at_tick {
|
||||
entry.zone_inference = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NaiveSpatialIndex>();
|
||||
world.init_resource::<VisibilityGeometry>();
|
||||
world.init_resource::<ActivePerceptionMode>();
|
||||
world
|
||||
}
|
||||
|
||||
fn pos(x: i32, y: i32) -> TilePosition {
|
||||
TilePosition::new(x, y, 0)
|
||||
}
|
||||
|
||||
// --- compute_npc_vision tests ---
|
||||
|
||||
#[test]
|
||||
fn npc_sees_nearby_entity_in_open_field() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
// Player entity (required for player_stable_id lookup)
|
||||
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.contains(&target_sid),
|
||||
"NPC should see nearby entity in open field"
|
||||
);
|
||||
assert!(!vision.player_visible, "player is far away");
|
||||
let _ = npc_sid; // registered for completeness
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_cannot_see_through_wall() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target behind a wall
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
// Wall between NPC and target
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&pos(16, 15), false);
|
||||
drop(walkability);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity behind wall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_detects_player_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
registry.register(player);
|
||||
spatial.update(player, pos(16, 14));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(vision.player_visible, "NPC should detect player in LOS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_entity_on_different_z_level() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target at same x/y but different z
|
||||
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, TilePosition::new(16, 14, 1));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity on different z-level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_entity_beyond_range() {
|
||||
let mut world = setup_world(64, 64);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Target beyond NPC_VISION_RANGE (20 tiles)
|
||||
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&target_sid),
|
||||
"NPC should not see entity beyond vision range"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_does_not_see_self() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
!vision.visible_entities.contains(&npc_sid),
|
||||
"NPC should not include itself in visible entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_sees_non_npc_entity_on_same_tile() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// NPC at (16, 16)
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
// Non-NPC entity on the same tile (e.g. dropped item)
|
||||
let item = world.spawn(pos(16, 16)).id();
|
||||
let item_sid = registry.register(item);
|
||||
spatial.update(item, pos(16, 16));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.contains(&item_sid),
|
||||
"NPC should see non-NPC entity sharing its tile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_npc_not_processed() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let mut spatial = NaiveSpatialIndex::new();
|
||||
|
||||
// Background NPC — should NOT have its vision computed
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
crate::simulation::tier::BackgroundSim,
|
||||
pos(16, 16),
|
||||
NpcVisionState::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
spatial.update(npc, pos(16, 16));
|
||||
|
||||
let target = world.spawn(pos(16, 14)).id();
|
||||
let target_sid = registry.register(target);
|
||||
spatial.update(target, pos(16, 14));
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(spatial);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_npc_vision);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let vision = world.get::<NpcVisionState>(npc).unwrap();
|
||||
assert!(
|
||||
vision.visible_entities.is_empty(),
|
||||
"Background NPC should not have vision computed"
|
||||
);
|
||||
let _ = target_sid;
|
||||
}
|
||||
|
||||
// --- emit_npc_vision_events tests ---
|
||||
|
||||
#[test]
|
||||
fn npc_seeing_player_emits_direct_observation() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let mut vision = NpcVisionState::default();
|
||||
vision.visible_entities.insert(player_sid);
|
||||
vision.player_visible = true;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(!queue.is_empty(), "should emit DirectObservation for player");
|
||||
|
||||
let event = &queue.events[0];
|
||||
assert_eq!(event.observer, npc);
|
||||
assert!(matches!(
|
||||
event.event_type,
|
||||
KnowledgeEventType::DirectObservation { target, .. } if target == player
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_losing_player_emits_left_los() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// NPC that had Direct knowledge of player (player was just visible)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(player_sid, pos(16, 14), 50);
|
||||
|
||||
// Memory with last known position
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(16, 14),
|
||||
observed_tick: 50,
|
||||
zone_inference: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Vision state: player NOT visible now
|
||||
let vision = NpcVisionState::default();
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 60;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
let has_left_los = queue.events.iter().any(|e| {
|
||||
matches!(
|
||||
e.event_type,
|
||||
KnowledgeEventType::LeftLOS { target } if target == player
|
||||
)
|
||||
});
|
||||
assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS");
|
||||
|
||||
// Check zone inference was created
|
||||
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
|
||||
let entry = npc_memory.last_known.get(&player_sid).unwrap();
|
||||
assert!(
|
||||
entry.zone_inference.is_some(),
|
||||
"zone inference should be created when player leaves LOS"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
|
||||
60 + INFERENCE_DEGRADE_TICKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_updates_memory_on_player_observation() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let mut vision = NpcVisionState::default();
|
||||
vision.visible_entities.insert(player_sid);
|
||||
vision.player_visible = true;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let memory = world.get::<NpcMemory>(npc).unwrap();
|
||||
let entry = memory.last_known.get(&player_sid).unwrap();
|
||||
assert_eq!(entry.position, pos(10, 10));
|
||||
assert_eq!(entry.observed_tick, 100);
|
||||
assert!(entry.zone_inference.is_none(), "active observation = no inference");
|
||||
}
|
||||
|
||||
// --- degrade_npc_inferences tests ---
|
||||
|
||||
#[test]
|
||||
fn inference_degrades_after_threshold() {
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
let player_sid = StableId(1);
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
zone_inference: Some(ZoneInference {
|
||||
last_seen_position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
degrades_at_tick: 100,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||
|
||||
// Tick 90 (before degradation, on minute boundary)
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 90;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(degrade_npc_inferences);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||
"inference should survive before degradation tick"
|
||||
);
|
||||
|
||||
// Tick 100 (degradation tick, on minute boundary)
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 100;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule2.add_systems(degrade_npc_inferences);
|
||||
schedule2.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_none(),
|
||||
"inference should be removed at degradation tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_degradation_skips_non_minute_ticks() {
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
let player_sid = StableId(1);
|
||||
let mut memory = NpcMemory::default();
|
||||
memory.last_known.insert(
|
||||
player_sid,
|
||||
LastKnownEntry {
|
||||
position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
zone_inference: Some(ZoneInference {
|
||||
last_seen_position: pos(10, 10),
|
||||
observed_tick: 50,
|
||||
degrades_at_tick: 55, // already past
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let npc = world.spawn((Npc, ActiveSim, memory)).id();
|
||||
|
||||
// Tick 57 — past degradation but NOT a minute boundary
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 57;
|
||||
world.insert_resource(time);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(degrade_npc_inferences);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mem = world.get::<NpcMemory>(npc).unwrap();
|
||||
assert!(
|
||||
mem.last_known[&player_sid].zone_inference.is_some(),
|
||||
"degradation should not run on non-minute ticks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_events_when_no_player_entity() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let registry = EntityRegistry::new(0);
|
||||
|
||||
let vision = NpcVisionState::default();
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
pos(16, 16),
|
||||
vision,
|
||||
NpcMemory::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(emit_npc_vision_events);
|
||||
schedule.run(&mut world); // should not panic
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(queue.is_empty(), "no events when no player entity exists");
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,13 @@ use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::examine::ExamineResultBuffer;
|
||||
use crate::simulation::follow::FollowTarget;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::poi::PointOfInterest;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
@@ -71,7 +73,7 @@ pub fn compute_observer_snapshot(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&Facing>,
|
||||
&KnowledgeGraph,
|
||||
Ref<KnowledgeGraph>,
|
||||
&mut NearbyInteractionBuffer,
|
||||
&mut MonologueBuffer,
|
||||
Option<&Stance>,
|
||||
@@ -82,6 +84,7 @@ pub fn compute_observer_snapshot(
|
||||
Option<&mut ScanEventBuffer>,
|
||||
Option<&mut ConversationEventBuffer>,
|
||||
Option<&FollowTarget>,
|
||||
Option<&mut ExamineResultBuffer>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -94,8 +97,10 @@ pub fn compute_observer_snapshot(
|
||||
Option<&crate::npc::tell_state::DerivedTellState>,
|
||||
)>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
poi_query: Query<&PointOfInterest>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
sim_rng: Option<Res<SimRng>>,
|
||||
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Ok((
|
||||
observer_entity,
|
||||
@@ -112,12 +117,17 @@ pub fn compute_observer_snapshot(
|
||||
mut scan_event_buffer_opt,
|
||||
mut conversation_buffer_opt,
|
||||
follow_target_opt,
|
||||
mut examine_result_buffer_opt,
|
||||
)) = observer_query.single_mut()
|
||||
else {
|
||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||
return;
|
||||
};
|
||||
|
||||
// D-041 dirty flag: only re-serialize KG dump when observer's graph changed this tick.
|
||||
let kg_changed = observer_kg.is_changed();
|
||||
let observer_kg: &KnowledgeGraph = &observer_kg;
|
||||
|
||||
let facing = facing_opt
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
@@ -193,6 +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()).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())
|
||||
@@ -272,6 +289,104 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
});
|
||||
|
||||
// Collect discovered POIs for minimap (#151, D-013)
|
||||
// A POI is "discovered" if the observer's KG contains fact "poi.{poi_id}".
|
||||
let poi_list: Vec<PoiWire> = poi_query
|
||||
.iter()
|
||||
.filter(|poi| observer_kg.knows_fact(&poi.fact_id()))
|
||||
.map(|poi| PoiWire {
|
||||
poi_id: poi.poi_id.clone(),
|
||||
name: poi.name.clone(),
|
||||
x: poi.position.x,
|
||||
y: poi.position.y,
|
||||
z: poi.position.z,
|
||||
category: poi.category,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build player knowledge dump for journal panel (#264, D-041).
|
||||
// Only re-serialize when the observer's KG was mutated this tick (Changed filter).
|
||||
// When unchanged, player_knowledge = None → field omitted from wire (skip_serializing_if).
|
||||
// Client keeps its last value (apply_snapshot only updates when field is present).
|
||||
let player_knowledge = if kg_changed {
|
||||
let kg_entities: Vec<KnownEntityWire> = observer_kg
|
||||
.known_entities_iter()
|
||||
.map(|(sid, ek)| {
|
||||
let name = ek
|
||||
.known_attributes
|
||||
.get("name")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let source = match &ek.source {
|
||||
crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => {
|
||||
"DirectObservation".to_string()
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(),
|
||||
crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => {
|
||||
format!("ToldBy({})", source_id.0)
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Inferred { .. } => {
|
||||
"Inferred".to_string()
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Background => {
|
||||
"Background".to_string()
|
||||
}
|
||||
};
|
||||
KnownEntityWire {
|
||||
entity_id: sid.0,
|
||||
name,
|
||||
confidence: ek.confidence,
|
||||
source,
|
||||
state: ek.state,
|
||||
relationship: ek.relationship,
|
||||
last_observed_tick: ek.last_observed_tick,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kg_facts: Vec<KnownFactWire> = observer_kg
|
||||
.known_facts_iter()
|
||||
.map(|(fid, fk)| {
|
||||
let source = match &fk.source {
|
||||
crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => {
|
||||
"DirectObservation".to_string()
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(),
|
||||
crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => {
|
||||
format!("ToldBy({})", source_id.0)
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Inferred { .. } => {
|
||||
"Inferred".to_string()
|
||||
}
|
||||
crate::knowledge::types::KnowledgeSource::Background => {
|
||||
"Background".to_string()
|
||||
}
|
||||
};
|
||||
KnownFactWire {
|
||||
fact_id: fid.0.clone(),
|
||||
confidence: fk.confidence,
|
||||
source,
|
||||
state: fk.state,
|
||||
acquired_tick: fk.acquired_tick,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if kg_entities.is_empty() && kg_facts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PlayerKnowledgeWire {
|
||||
entities: kg_entities,
|
||||
facts: kg_facts,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
@@ -290,8 +405,15 @@ pub fn compute_observer_snapshot(
|
||||
conversation_events,
|
||||
conversation_ended,
|
||||
follow_state,
|
||||
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,
|
||||
player_knowledge,
|
||||
save_result,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #337 — Tell state → snapshot integration (D-024 tell system)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Helper: run derive_tell_state + two-stage observer pipeline together.
|
||||
fn run_tell_plus_observer_pipeline(world: &mut World) {
|
||||
use crate::npc::tell_state::derive_tell_state;
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
derive_tell_state,
|
||||
compute_visibility_geometry.after(derive_tell_state),
|
||||
compute_observer_snapshot
|
||||
.after(compute_visibility_geometry)
|
||||
.after(derive_tell_state),
|
||||
));
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
|
||||
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
|
||||
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
|
||||
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
|
||||
// → compute_observer_snapshot → VisibleEntity.tell_state.
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::tell_state::{DerivedTellState, TellCategory};
|
||||
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
|
||||
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(16, 14, 0),
|
||||
Secret {
|
||||
description: "criminal record".into(),
|
||||
severity: SecretSeverity::Major,
|
||||
known_by: vec![],
|
||||
},
|
||||
ToleranceThreshold { current_stress: 60, threshold: 100 },
|
||||
Contentment { level: 0 },
|
||||
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||
DerivedTellState::default(),
|
||||
));
|
||||
|
||||
run_tell_plus_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
Some(TellCategory::Nervous),
|
||||
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_none_for_neutral_npc_in_snapshot() {
|
||||
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
|
||||
// Verifies the pipeline correctly omits tell when no conditions are met.
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::tell_state::DerivedTellState;
|
||||
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(16, 14, 0),
|
||||
Secret {
|
||||
description: "minor embarrassment".into(),
|
||||
severity: SecretSeverity::Minor,
|
||||
known_by: vec![],
|
||||
},
|
||||
ToleranceThreshold { current_stress: 10, threshold: 100 },
|
||||
Contentment { level: 0 },
|
||||
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
|
||||
DerivedTellState::default(),
|
||||
));
|
||||
|
||||
run_tell_plus_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
None,
|
||||
"neutral NPC should have no tell state in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tell_state_none_when_npc_has_no_derived_tell_component() {
|
||||
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
|
||||
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
// NPC with no DerivedTellState component at all
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible in snapshot");
|
||||
|
||||
assert_eq!(
|
||||
npc.tell_state,
|
||||
None,
|
||||
"NPC without DerivedTellState component should have tell_state = None"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Examine interaction system (#242).
|
||||
//!
|
||||
//! Handles the Examine verb: player examines an NPC or object at close range,
|
||||
//! generating character-filtered observation text and a DirectObservation
|
||||
//! KnowledgeGraph entry.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
|
||||
//! → process_player_input inserts ExamineRequest on player
|
||||
//! → process_examine_interaction reads request, generates text, pushes KG event
|
||||
//! → ExamineResultBuffer consumed by compute_observer_snapshot
|
||||
//! → ObserverSnapshot.examine_result delivered to client
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::types::CharacterArchetype;
|
||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
||||
use crate::simulation::interaction::CLOSE_RANGE;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker: player requested Examine interaction with a target entity this tick.
|
||||
///
|
||||
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
|
||||
/// "Examine Object", or "ExamineObject". Consumed and removed by
|
||||
/// process_examine_interaction each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ExamineRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Character-filtered examination result for snapshot delivery.
|
||||
///
|
||||
/// Content differs per CharacterArchetype:
|
||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultEvent {
|
||||
/// Character-filtered observation text for client display.
|
||||
pub text: String,
|
||||
/// Wire-format entity identifier of the examined entity.
|
||||
pub target_entity_id: u64,
|
||||
}
|
||||
|
||||
/// Buffer holding the examine result for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
/// Attach to the player entity alongside other buffer components.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct ExamineResultBuffer {
|
||||
pub(crate) result: Option<ExamineResultEvent>,
|
||||
}
|
||||
|
||||
impl ExamineResultBuffer {
|
||||
/// Drain and return the examine result, leaving the buffer empty.
|
||||
pub fn take(&mut self) -> Option<ExamineResultEvent> {
|
||||
self.result.take()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generation (deterministic, integer-only — D-010)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
|
||||
/// Uses integer multiplication to avoid division by zero.
|
||||
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
|
||||
if threshold.threshold <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
|
||||
as u8
|
||||
}
|
||||
|
||||
/// Map NpcMood to a terse descriptor shared by both archetypes.
|
||||
fn mood_word(mood: NpcMood) -> &'static str {
|
||||
match mood {
|
||||
NpcMood::Neutral => "neutral",
|
||||
NpcMood::Anxious => "anxious",
|
||||
NpcMood::Frustrated => "frustrated",
|
||||
NpcMood::Content => "at ease",
|
||||
NpcMood::Suspicious => "watchful",
|
||||
NpcMood::Warm => "open",
|
||||
NpcMood::Hostile => "hostile",
|
||||
NpcMood::Focused => "focused",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
|
||||
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate character-filtered examination text from NPC component state.
|
||||
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||
pub fn generate_examine_text(
|
||||
mood: NpcMood,
|
||||
ratio: u8,
|
||||
archetype: CharacterArchetype,
|
||||
traits_opt: Option<&PersonalityTraits>,
|
||||
) -> String {
|
||||
let stress_label = match ratio {
|
||||
0..=30 => "relaxed",
|
||||
31..=60 => "tense",
|
||||
61..=85 => "stressed",
|
||||
_ => "near breaking point",
|
||||
};
|
||||
|
||||
let mood_label = mood_word(mood);
|
||||
|
||||
match archetype {
|
||||
CharacterArchetype::Smuggler => {
|
||||
// Physical threat read + cargo opportunity window
|
||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||
"Threat posture. Don't push it."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||
"Confident bearing. Will push back if cornered."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||
"Nervous type. Predictable under pressure."
|
||||
} else {
|
||||
"No obvious threat read."
|
||||
};
|
||||
|
||||
let window = if ratio > 60 {
|
||||
"Too distracted to track cargo movement."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention to this section."
|
||||
} else {
|
||||
"Standard patrol pattern. Window is there."
|
||||
};
|
||||
|
||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||
}
|
||||
|
||||
CharacterArchetype::Detective => {
|
||||
// Procedural tells + behavioral read
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process examine interaction: generate character-filtered observation text,
|
||||
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn process_examine_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut kg_events: ResMut<KnowledgeEventQueue>,
|
||||
mut player_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&ExamineRequest,
|
||||
Option<&CharacterArchetype>,
|
||||
&mut ExamineResultBuffer,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
npc_query: Query<(
|
||||
&TilePosition,
|
||||
Option<&MoodState>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&PersonalityTraits>,
|
||||
)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Range check — examine requires close range (same as Talk/Confront)
|
||||
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
|
||||
tracing::warn!(?target, "process_examine_interaction: target not in query");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
};
|
||||
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
CLOSE_RANGE
|
||||
);
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
|
||||
// Push DirectObservation to KnowledgeEventQueue
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: *target_pos,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve target wire ID for snapshot
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
result_buffer.result = Some(ExamineResultEvent {
|
||||
text,
|
||||
target_entity_id,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_entity_id,
|
||||
"Examine: DirectObservation pushed, result written to buffer"
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::PersonalityTrait;
|
||||
|
||||
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
|
||||
PersonalityTraits { traits: t.to_vec() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Hostile,
|
||||
20,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Focused,
|
||||
30,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("close attention"), "expected attention note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_high_stress_identifies_distraction() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
80,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_deceptive_npc_notes_concealment() {
|
||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Neutral,
|
||||
20,
|
||||
CharacterArchetype::Detective,
|
||||
Some(&t),
|
||||
);
|
||||
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
50,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("stress markers"),
|
||||
"expected stress markers, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Content,
|
||||
10,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("Less guarded"),
|
||||
"expected low guard note, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_zero_when_threshold_zero() {
|
||||
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_clamped_at_100() {
|
||||
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
|
||||
assert_eq!(stress_ratio(&t), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_negative_stress_is_zero() {
|
||||
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn examine_result_buffer_take_drains() {
|
||||
let mut buf = ExamineResultBuffer::default();
|
||||
assert!(buf.take().is_none());
|
||||
buf.result = Some(ExamineResultEvent {
|
||||
text: "Test".into(),
|
||||
target_entity_id: 42,
|
||||
});
|
||||
assert!(buf.take().is_some());
|
||||
assert!(buf.take().is_none()); // idempotent drain
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -221,6 +227,16 @@ pub fn process_player_input(
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
|
||||
| Some("ExamineObject") | Some("Observe") => {
|
||||
handle_examine(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Confront") => {
|
||||
handle_confront(
|
||||
&mut commands,
|
||||
@@ -296,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +517,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
|
||||
/// Examine is available at close range (same as Talk). Range check here matches
|
||||
/// the server-side guard in process_examine_interaction.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_examine(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut crate::simulation::stance::Stance>,
|
||||
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Examine verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Examine: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Examine if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::examine::ExamineRequest {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
||||
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
||||
///
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user